Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aba89c063 | ||
|
|
5fa5d45535 |
@@ -1 +0,0 @@
|
||||
2602260022
|
||||
+41
-8
@@ -1,11 +1,44 @@
|
||||
.git
|
||||
.env
|
||||
*.log
|
||||
data/*
|
||||
# Release builds accept only application sources and explicit build inputs.
|
||||
# Local configuration, databases, backups, Git metadata and tool caches must
|
||||
# never be sent to the builder, even if new directories are added to the repo.
|
||||
**
|
||||
!Dockerfile
|
||||
!.dockerignore
|
||||
!LICENSE
|
||||
!backend/
|
||||
!backend/requirements.txt
|
||||
!backend/app/
|
||||
!backend/app/**
|
||||
!frontend/
|
||||
!frontend/package.json
|
||||
!frontend/package-lock.json
|
||||
!frontend/next-env.d.ts
|
||||
!frontend/next.config.js
|
||||
!frontend/proxy.ts
|
||||
!frontend/tsconfig.json
|
||||
!frontend/app/
|
||||
!frontend/app/**
|
||||
!frontend/public/
|
||||
!frontend/public/**
|
||||
!docker/
|
||||
!docker/supervisord.conf
|
||||
!docker/requirements-runtime.txt
|
||||
!data/
|
||||
!data/branding/
|
||||
!data/branding/**
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
backend/__pycache__/
|
||||
**/__pycache__/
|
||||
|
||||
# Defense in depth for accidental private/generated files under allowed paths.
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/*.log
|
||||
**/*.db
|
||||
**/*.db-*
|
||||
**/*.sqlite
|
||||
**/*.sqlite3
|
||||
**/bootstrap-admin.json
|
||||
**/bootstrap-secrets.json
|
||||
**/.magent-secrets-*
|
||||
**/node_modules
|
||||
**/.next
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Copy to .env for a fresh install; never replace an existing deployment's keys.
|
||||
# See docs/PUBLIC_RELEASE.md. The localhost settings below are for local HTTP only.
|
||||
# Never deploy the example secret placeholders.
|
||||
APP_NAME=Magent
|
||||
|
||||
# Public Docker Hub template: choose a published prod-<commit> tag or sha256 digest.
|
||||
# Intentionally no default: do not silently pull a mutable or incompatible image.
|
||||
MAGENT_IMAGE=
|
||||
MAGENT_BIND_ADDRESS=127.0.0.1
|
||||
MAGENT_HTTP_PORT=3000
|
||||
|
||||
# For public hosting set BOTH URLs to your exact HTTPS origin (no trailing slash),
|
||||
# for example https://magent.example.com, and AUTH_COOKIE_SECURE=true below.
|
||||
CORS_ALLOW_ORIGIN=http://localhost:3000
|
||||
MAGENT_APPLICATION_URL=http://localhost:3000
|
||||
# Backend address is internal to the combined container, not a browser endpoint.
|
||||
MAGENT_API_URL=http://127.0.0.1:8000
|
||||
SQLITE_PATH=/app/data/magent.db
|
||||
LOG_FILE=/app/data/magent.log
|
||||
LOG_FORMAT=text
|
||||
|
||||
# Generate independent values as documented in docs/PUBLIC_RELEASE.md.
|
||||
# Keep both unchanged when upgrading or restoring an offline data-volume backup.
|
||||
JWT_SECRET=replace-with-at-least-32-random-characters
|
||||
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
|
||||
ADMIN_USERNAME=admin
|
||||
# Recommended fresh install: generate a separate random setup token. Open /setup
|
||||
# to create the administrator and connect your apps; remove this after finishing.
|
||||
SETUP_TOKEN=replace-with-a-separate-random-setup-token
|
||||
# Alternatively pre-create the first admin with a unique password (12+ chars).
|
||||
# Leave blank to create the account using the setup wizard and SETUP_TOKEN.
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# false is ONLY for local HTTP; public HTTPS deployments must use true.
|
||||
AUTH_COOKIE_SECURE=false
|
||||
AUTH_COOKIE_SAMESITE=strict
|
||||
API_DOCS_ENABLED=false
|
||||
@@ -0,0 +1,17 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.gz binary
|
||||
*.tgz binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
+19
-5
@@ -1,12 +1,26 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.venv/
|
||||
data/
|
||||
!data/branding/
|
||||
!data/branding/**
|
||||
backend/__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
backend/.pytest_cache/
|
||||
**/.pytest_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
*.tsbuildinfo
|
||||
*.log
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite*
|
||||
*.magent-backup
|
||||
bootstrap-admin.json
|
||||
bootstrap-secrets.json
|
||||
.magent-secrets-*
|
||||
data/*
|
||||
!data/branding/
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
+67
-24
@@ -1,53 +1,96 @@
|
||||
FROM node:20-slim AS frontend-builder
|
||||
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
# GNU cp is needed only to collect third-party notices in the builder.
|
||||
RUN apk add --no-cache coreutils
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
|
||||
NEXT_PUBLIC_API_BASE=/api
|
||||
|
||||
COPY frontend/package.json ./
|
||||
RUN npm install
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --include=dev
|
||||
|
||||
COPY frontend/app ./app
|
||||
COPY frontend/public ./public
|
||||
COPY frontend/next-env.d.ts ./next-env.d.ts
|
||||
COPY frontend/next.config.js ./next.config.js
|
||||
COPY frontend/proxy.ts ./proxy.ts
|
||||
COPY frontend/tsconfig.json ./tsconfig.json
|
||||
|
||||
RUN npm run build
|
||||
# Keep dependency notices outside the traced bundle: file tracing deliberately
|
||||
# omits many license files that still need to accompany redistributed packages.
|
||||
RUN npm run build \
|
||||
&& npm prune --omit=dev \
|
||||
&& mkdir /licenses \
|
||||
&& npm ls --omit=dev --all --json > /licenses/dependencies.json \
|
||||
&& find node_modules -type f \
|
||||
\( -iname 'license*' -o -iname 'copying*' -o -iname 'notice*' -o -iname 'copyright*' \) \
|
||||
-exec cp --parents -t /licenses {} +
|
||||
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.14-alpine@sha256:016508ba505da24f7139765bc4bb669df4e88eb2f12eeadd571bf2f88d7533df AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
NODE_ENV=production
|
||||
MAGENT_MANAGED_SECRETS=auto \
|
||||
SQLITE_PATH=/app/data/magent.db \
|
||||
API_DOCS_ENABLED=false \
|
||||
NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Keep curl for existing deployments that override the image healthcheck.
|
||||
# Copy only Node's runtime binary: npm, headers and the NodeSource installer
|
||||
# are build tools, not dependencies of the standalone frontend server.
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --no-cache curl libstdc++
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=frontend-builder /usr/local/LICENSE /usr/local/share/doc/nodejs/LICENSE
|
||||
RUN node --version
|
||||
|
||||
COPY backend/app ./app
|
||||
COPY data/branding /app/data/branding
|
||||
ARG MAGENT_UID=1000
|
||||
ARG MAGENT_GID=1000
|
||||
RUN addgroup -g ${MAGENT_GID} magent \
|
||||
&& adduser -D -u ${MAGENT_UID} -G magent -s /sbin/nologin magent \
|
||||
&& install -d -o magent -g magent -m 0700 /app/data \
|
||||
&& install -d -o magent -g magent -m 0755 /app/frontend/.next/cache
|
||||
|
||||
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 backend/requirements.txt docker/requirements-runtime.txt /tmp/requirements/
|
||||
RUN pip install --no-cache-dir --no-compile \
|
||||
-r /tmp/requirements/requirements.txt \
|
||||
-r /tmp/requirements/requirements-runtime.txt \
|
||||
&& pip uninstall -y pip \
|
||||
&& rm /tmp/requirements/requirements.txt /tmp/requirements/requirements-runtime.txt \
|
||||
&& rmdir /tmp/requirements
|
||||
|
||||
COPY --chown=magent:magent backend/app ./app
|
||||
COPY --chown=magent:magent data/branding /app/data/branding
|
||||
|
||||
# Next's traced standalone output excludes the full dev/build dependency tree.
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/.next/standalone /app/frontend
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/.next/static /app/frontend/.next/static
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public
|
||||
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
|
||||
COPY LICENSE /usr/share/licenses/magent/LICENSE
|
||||
COPY --from=frontend-builder /licenses /usr/share/licenses/magent/frontend
|
||||
|
||||
LABEL org.opencontainers.image.title="Magent" \
|
||||
org.opencontainers.image.description="Self-hosted media requests, issues and viewing insights" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
USER magent:magent
|
||||
|
||||
EXPOSE 3000 8000
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
|
||||
CMD curl --fail --silent --show-error --max-time 2 http://127.0.0.1:8000/health >/dev/null \
|
||||
&& curl --fail --silent --show-error --max-time 2 http://127.0.0.1:3000/login >/dev/null \
|
||||
|| exit 1
|
||||
|
||||
ENTRYPOINT ["python", "-m", "app.container_bootstrap"]
|
||||
CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Magent contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,169 +1,104 @@
|
||||
# Magent
|
||||
|
||||
Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
|
||||
Self-hosted media requests, viewing stats and issue management for Jellyfin,
|
||||
Seerr, Sonarr, Radarr and related services. Magent combines a Python/FastAPI API,
|
||||
a Next.js frontend and SQLite in one non-root container.
|
||||
|
||||
## How it works
|
||||
## Install
|
||||
|
||||
1) Requests are pulled from Jellyseerr and stored locally.
|
||||
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
|
||||
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
|
||||
4) The UI renders a timeline and a central status box for each request.
|
||||
5) Optional AI triage summarizes the likely cause and safest next steps.
|
||||
Paste [compose.yml](compose.yml) into a Portainer **Docker Standalone** stack.
|
||||
It uses `rephl3xnz/magent:latest`, persists data in a named volume and needs no
|
||||
environment variables or Dockerfile on the user's machine.
|
||||
|
||||
## Core features
|
||||
**Image availability:** the managed-install image is published on Docker Hub.
|
||||
Only Linux/amd64 has been validated. `latest` is mutable; record the resolved
|
||||
image digest before updating, or pin an immutable release tag.
|
||||
|
||||
- Request search by title/year or request ID.
|
||||
- Recent requests list with posters and status.
|
||||
- Timeline view across Jellyseerr, Arr, Prowlarr, qBittorrent, Jellyfin.
|
||||
- Central status box with clear reason + next steps.
|
||||
- Safe action buttons (search, resume, re-add, etc.).
|
||||
- Admin settings for service URLs, API keys, profiles, and root folders.
|
||||
- Health status for each service in the pipeline.
|
||||
- Cache and sync controls (full sync, delta sync, scheduled syncs).
|
||||
- Local database for speed and audit history.
|
||||
- Users and access control (admin vs user, block access).
|
||||
- Local account password changes via "My profile".
|
||||
- Docker-first deployment for easy hosting.
|
||||
1. Deploy the stack and wait for the container to become healthy.
|
||||
2. In its console, select `/bin/ash` and user `magent`, then run:
|
||||
|
||||
## Quick start (Docker - primary)
|
||||
```sh
|
||||
python -m app.container_bootstrap setup-token
|
||||
```
|
||||
|
||||
Docker is the recommended way to run Magent. It includes the backend and frontend with sane defaults.
|
||||
3. Open the Docker host's address on port 3000. Confirm the browser-facing URL
|
||||
in setup and use the token to create the first administrator.
|
||||
The **Get setup token** button shows the console instructions and lets you
|
||||
copy the command; it never reveals the token to public visitors.
|
||||
4. Connect your apps, choose preferences and finish setup. Optional apps can
|
||||
be skipped. Save an encrypted backup afterwards.
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
Keep the Compose security block unchanged. Database storage is fixed at
|
||||
`/app/data/magent.db` and API docs are disabled in managed installs. CORS and
|
||||
cookie security follow the confirmed URL. Use HTTPS before public access.
|
||||
|
||||
See [Portainer setup](docs/PORTAINER.md),
|
||||
[all environment options](docs/ENVIRONMENT.md),
|
||||
[backup and restore](docs/installation-and-recovery.md) and
|
||||
[advanced installation/upgrades](docs/PUBLIC_RELEASE.md).
|
||||
Existing installations must retain their original data volume and signing/
|
||||
encryption keys; this fresh-install template is not an automatic migration.
|
||||
|
||||
## Build and test
|
||||
|
||||
The source tree contains everything needed to build the application:
|
||||
|
||||
```sh
|
||||
docker compose -f compose.yml -f compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
Then open:
|
||||
For a disposable verification run, without touching an existing installation:
|
||||
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend: http://localhost:8000
|
||||
|
||||
### Docker setup steps
|
||||
|
||||
1) Create `.env` with your service URLs and API keys.
|
||||
2) Run `docker compose up --build`.
|
||||
3) Log in at http://localhost:3000.
|
||||
4) Visit Settings to confirm service health.
|
||||
|
||||
### Docker environment variables (sample)
|
||||
|
||||
```bash
|
||||
JELLYSEERR_URL="http://localhost:5055"
|
||||
JELLYSEERR_API_KEY="..."
|
||||
SONARR_URL="http://localhost:8989"
|
||||
SONARR_API_KEY="..."
|
||||
SONARR_QUALITY_PROFILE_ID="1"
|
||||
SONARR_ROOT_FOLDER="/tv"
|
||||
RADARR_URL="http://localhost:7878"
|
||||
RADARR_API_KEY="..."
|
||||
RADARR_QUALITY_PROFILE_ID="1"
|
||||
RADARR_ROOT_FOLDER="/movies"
|
||||
PROWLARR_URL="http://localhost:9696"
|
||||
PROWLARR_API_KEY="..."
|
||||
QBIT_URL="http://localhost:8080"
|
||||
QBIT_USERNAME="..."
|
||||
QBIT_PASSWORD="..."
|
||||
SQLITE_PATH="data/magent.db"
|
||||
JWT_SECRET="change-me"
|
||||
JWT_EXP_MINUTES="720"
|
||||
ADMIN_USERNAME="admin"
|
||||
ADMIN_PASSWORD="adminadmin"
|
||||
```sh
|
||||
docker build -t magent:review .
|
||||
bash scripts/ci_container_smoke.sh magent:review
|
||||
MAGENT_SMOKE_MANAGED=true bash scripts/ci_container_smoke.sh magent:review
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
Unit checks require Python 3.14 and Node 24:
|
||||
|
||||
Add screenshots here once available:
|
||||
|
||||
- `docs/screenshots/home.png`
|
||||
- `docs/screenshots/request-timeline.png`
|
||||
- `docs/screenshots/settings.png`
|
||||
- `docs/screenshots/profile.png`
|
||||
|
||||
## Local development (secondary)
|
||||
|
||||
Use this only when you need to modify code locally.
|
||||
|
||||
### Backend (FastAPI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
```sh
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
Environment variables (sample):
|
||||
|
||||
```bash
|
||||
$env:JELLYSEERR_URL="http://localhost:5055"
|
||||
$env:JELLYSEERR_API_KEY="..."
|
||||
$env:SONARR_URL="http://localhost:8989"
|
||||
$env:SONARR_API_KEY="..."
|
||||
$env:SONARR_QUALITY_PROFILE_ID="1"
|
||||
$env:SONARR_ROOT_FOLDER="/tv"
|
||||
$env:RADARR_URL="http://localhost:7878"
|
||||
$env:RADARR_API_KEY="..."
|
||||
$env:RADARR_QUALITY_PROFILE_ID="1"
|
||||
$env:RADARR_ROOT_FOLDER="/movies"
|
||||
$env:PROWLARR_URL="http://localhost:9696"
|
||||
$env:PROWLARR_API_KEY="..."
|
||||
$env:QBIT_URL="http://localhost:8080"
|
||||
$env:QBIT_USERNAME="..."
|
||||
$env:QBIT_PASSWORD="..."
|
||||
$env:SQLITE_PATH="data/magent.db"
|
||||
$env:JWT_SECRET="change-me"
|
||||
$env:JWT_EXP_MINUTES="720"
|
||||
$env:ADMIN_USERNAME="admin"
|
||||
$env:ADMIN_PASSWORD="adminadmin"
|
||||
```
|
||||
|
||||
### Frontend (Next.js)
|
||||
|
||||
```bash
|
||||
. .venv/bin/activate
|
||||
pip install -r backend/requirements-dev.txt
|
||||
python -m unittest discover -s backend/tests -p 'test_*.py'
|
||||
python scripts/check_environment_docs.py
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
npm ci
|
||||
npm test
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Open http://localhost:3000
|
||||
On Windows, activate `.venv\Scripts\Activate.ps1` instead. Do not point tests
|
||||
at live services or use production credentials.
|
||||
|
||||
Admin panel: http://localhost:3000/admin
|
||||
## How it is organised
|
||||
|
||||
Login uses the admin credentials above (or any other local user you create in SQLite).
|
||||
- `backend/app/routers/`: authenticated API endpoints and administration.
|
||||
- `backend/app/clients/`: media-service clients; `services/`: request states,
|
||||
synchronisation, notifications, setup and encrypted backups.
|
||||
- `backend/app/db.py` and `schema_migrations.py`: SQLite persistence/migrations.
|
||||
- `frontend/app/`: pages and shared interface components; `frontend/proxy.ts`:
|
||||
browser security headers and request nonces.
|
||||
- `backend/tests/` and frontend `*.test.*`: synthetic regression tests.
|
||||
- `Dockerfile` and `docker/`: multi-stage build and process supervision.
|
||||
- `compose.yml`: prebuilt-image install; `compose.build.yml`: source override.
|
||||
|
||||
## Public Hosting Notes
|
||||
Requests are cached from Seerr, joined to collector/download/library evidence,
|
||||
normalised into a user-facing state and displayed by the frontend. App settings
|
||||
are stored in SQLite; sensitive settings are encrypted with installation-specific
|
||||
keys. Integrations are optional and are configured through the setup wizard.
|
||||
|
||||
The frontend proxies `/api/*` to the backend container. Set:
|
||||
This `release` branch intentionally excludes internal deployment scripts,
|
||||
environment files, runtime data, development reports and prior Git history.
|
||||
It contains no workflow that automatically deploys or publishes an image.
|
||||
|
||||
- `NEXT_PUBLIC_API_BASE=/api` (browser uses same-origin)
|
||||
- `BACKEND_INTERNAL_URL=http://backend:8000` (container-to-container)
|
||||
## Contributing and security
|
||||
|
||||
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
||||
Keep changes focused, add regression tests and run the checks above. Never
|
||||
commit tokens, database exports, backups or real user information.
|
||||
See [SECURITY.md](SECURITY.md) for reporting guidance and deployment precautions.
|
||||
|
||||
## History endpoints
|
||||
|
||||
- `GET /requests/{id}/history?limit=10` recent snapshots
|
||||
- `GET /requests/{id}/actions?limit=10` recent action logs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Login fails
|
||||
|
||||
- Make sure `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set in `.env`.
|
||||
- Confirm the backend is reachable: `http://localhost:8000/health` (or see container logs).
|
||||
|
||||
### Services show as down
|
||||
|
||||
- Check the URLs and API keys in Settings.
|
||||
- Verify containers can reach each service (network/DNS).
|
||||
|
||||
### No recent requests
|
||||
|
||||
- Confirm Jellyseerr credentials in Settings.
|
||||
- Run a full sync from Settings -> Requests.
|
||||
|
||||
### Docker images not updating
|
||||
|
||||
- Run `docker compose up --build` again.
|
||||
- If needed, run `docker compose down` first, then rebuild.
|
||||
Licensed under [MIT](LICENSE). Third-party dependency licences remain applicable.
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Security
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Do not post passwords, access tokens, encryption keys, database exports, backup
|
||||
files or live exploit details in public issues, discussions or container logs.
|
||||
Use the repository hosting platform's private vulnerability-reporting feature
|
||||
if the release owner has enabled it. Otherwise contact the maintainer privately
|
||||
through the platform where you obtained this release before sending sensitive
|
||||
details. This repository does not currently advertise a dedicated reporting
|
||||
address; the release owner must establish one before a broad public launch.
|
||||
|
||||
Include the image tag/digest, affected version, a minimal reproduction using
|
||||
synthetic data, and the security impact. Remove deployment credentials and
|
||||
personal data from attachments. Do not test against systems you do not own or
|
||||
have permission to assess.
|
||||
|
||||
## Deployment precautions
|
||||
|
||||
Follow [the public installation guide](docs/PUBLIC_RELEASE.md): use HTTPS for
|
||||
public access, independent random secrets, a protected persistent data volume,
|
||||
and the exact browser-facing origin. Keep the original signing/encryption keys
|
||||
when upgrading. Do not disable origin checks or run as root to work around a
|
||||
deployment failure.
|
||||
|
||||
Use a reviewed immutable release image and retain a tested backup. Check the
|
||||
release's declared architecture support and migration notes. The project has
|
||||
not declared an LTS support window or a guaranteed security-response SLA.
|
||||
@@ -1,4 +0,0 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/app ./app
|
||||
COPY data/branding /app/data/branding
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -9,12 +9,12 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
|
||||
|
||||
if snapshot.state == NormalizedState.requested:
|
||||
root_cause = "approval"
|
||||
summary = "The request is waiting for approval in Jellyseerr."
|
||||
summary = "The request is waiting for approval in Seerr."
|
||||
recommendations.append(
|
||||
TriageRecommendation(
|
||||
action_id="wait_for_approval",
|
||||
title="Ask an admin to approve the request",
|
||||
reason="Jellyseerr has not marked this request as approved.",
|
||||
reason="Seerr has not marked this request as approved.",
|
||||
risk="low",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
+198
-16
@@ -1,32 +1,159 @@
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from .db import get_user_by_username, upsert_user_activity
|
||||
from .security import safe_decode_token, TokenError
|
||||
from .config import settings
|
||||
from .installation_origin import managed_runtime
|
||||
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
||||
from .network_security import request_trusts_forwarded_headers
|
||||
from .security import TokenError, safe_decode_token, verify_password
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def _is_expired(expires_at: str | None) -> bool:
|
||||
if not isinstance(expires_at, str) or not expires_at.strip():
|
||||
return False
|
||||
candidate = expires_at.strip()
|
||||
if candidate.endswith("Z"):
|
||||
candidate = candidate[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(candidate)
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed <= datetime.now(timezone.utc)
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||
|
||||
def _extract_client_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
return parts[0]
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
if request.client and request.client.host:
|
||||
return request.client.host
|
||||
direct_host = request.client.host if request.client else None
|
||||
if request_trusts_forwarded_headers(direct_host):
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
return parts[0]
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
if direct_host:
|
||||
return direct_host
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
|
||||
def _cookie_settings() -> dict[str, Any]:
|
||||
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
|
||||
if samesite not in {"lax", "strict", "none"}:
|
||||
samesite = "lax"
|
||||
secure = bool(settings.auth_cookie_secure)
|
||||
if managed_runtime():
|
||||
from .services.public_urls import magent_public_url
|
||||
# Follow the persisted operator-selected URL immediately, including
|
||||
# first login after setup; a restart is not required to protect cookies.
|
||||
secure = magent_public_url().startswith("https://")
|
||||
return {
|
||||
"secure": secure,
|
||||
"httponly": True,
|
||||
"samesite": samesite,
|
||||
"domain": settings.auth_cookie_domain or None,
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
|
||||
def _state_cookie_settings() -> dict[str, Any]:
|
||||
cookie = _cookie_settings()
|
||||
cookie["httponly"] = False
|
||||
return cookie
|
||||
|
||||
|
||||
def set_auth_cookies(response: Response, token: str) -> None:
|
||||
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
|
||||
response.set_cookie(
|
||||
settings.auth_cookie_name,
|
||||
token,
|
||||
max_age=max_age,
|
||||
**_cookie_settings(),
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.auth_state_cookie_name,
|
||||
"1",
|
||||
max_age=max_age,
|
||||
**_state_cookie_settings(),
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response) -> None:
|
||||
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
|
||||
response.delete_cookie(
|
||||
settings.auth_state_cookie_name,
|
||||
path="/",
|
||||
domain=settings.auth_cookie_domain or None,
|
||||
)
|
||||
|
||||
|
||||
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
return auth_header.split(" ", 1)[1].strip()
|
||||
if oauth_token:
|
||||
return oauth_token
|
||||
cookie_token = request.cookies.get(settings.auth_cookie_name)
|
||||
if isinstance(cookie_token, str) and cookie_token.strip():
|
||||
return cookie_token.strip()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
|
||||
if not isinstance(user, dict):
|
||||
return "local"
|
||||
provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
||||
if provider != "local":
|
||||
return provider
|
||||
password_hash = user.get("password_hash")
|
||||
if isinstance(password_hash, str) and password_hash:
|
||||
if verify_password("jellyfin-user", password_hash):
|
||||
return "jellyfin"
|
||||
if verify_password("jellyseerr-user", password_hash):
|
||||
return "jellyseerr"
|
||||
return provider
|
||||
|
||||
|
||||
def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(user, dict):
|
||||
return {}
|
||||
resolved_provider = resolve_user_auth_provider(user)
|
||||
stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
||||
if resolved_provider != stored_provider:
|
||||
username = str(user.get("username") or "").strip()
|
||||
if username:
|
||||
set_user_auth_provider(username, resolved_provider)
|
||||
refreshed_user = get_user_by_username(username)
|
||||
if refreshed_user:
|
||||
user = refreshed_user
|
||||
normalized = dict(user)
|
||||
normalized["auth_provider"] = resolved_provider
|
||||
normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
|
||||
normalized["password_provider"] = (
|
||||
resolved_provider if resolved_provider in {"local", "jellyfin"} else None
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _load_current_user_from_token(
|
||||
token: str,
|
||||
request: Optional[Request] = None,
|
||||
allowed_token_types: Optional[set[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
payload = safe_decode_token(token)
|
||||
except TokenError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||
token_type = str(payload.get("typ") or "access").strip().lower()
|
||||
if allowed_token_types and token_type not in allowed_token_types:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
@@ -37,6 +164,15 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
if user.get("is_blocked"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
if _is_expired(user.get("expires_at")):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
||||
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
|
||||
features = permissions(user)
|
||||
|
||||
if request is not None:
|
||||
ip = _extract_client_ip(request)
|
||||
@@ -44,15 +180,61 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
|
||||
upsert_user_activity(user["username"], ip, user_agent)
|
||||
|
||||
return {
|
||||
"features": features,
|
||||
"username": user["username"],
|
||||
"email": user.get("email"),
|
||||
"role": user["role"],
|
||||
"auth_provider": user.get("auth_provider", "local"),
|
||||
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
|
||||
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
|
||||
"invite_management_enabled": bool(user.get("invite_management_enabled", False)),
|
||||
"profile_id": user.get("profile_id"),
|
||||
"expires_at": user.get("expires_at"),
|
||||
"is_expired": bool(user.get("is_expired", False)),
|
||||
"password_change_supported": bool(user.get("password_change_supported", False)),
|
||||
"password_provider": user.get("password_provider"),
|
||||
"auth_version": int(user.get("auth_version") or 1),
|
||||
}
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
) -> Dict[str, Any]:
|
||||
resolved_token = _extract_access_token(request, token)
|
||||
if not resolved_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
return _load_current_user_from_token(resolved_token, request)
|
||||
|
||||
|
||||
def get_current_user_event_stream(
|
||||
request: Request,
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
) -> Dict[str, Any]:
|
||||
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
|
||||
resolved_token = _extract_access_token(request, token)
|
||||
stream_query_token = request.query_params.get("stream_token")
|
||||
if resolved_token:
|
||||
# Allow standard bearer tokens for non-browser EventSource clients.
|
||||
return _load_current_user_from_token(resolved_token, None)
|
||||
if not stream_query_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
return _load_current_user_from_token(
|
||||
str(stream_query_token),
|
||||
None,
|
||||
allowed_token_types={"sse"},
|
||||
)
|
||||
|
||||
|
||||
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin_event_stream(
|
||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
||||
) -> Dict[str, Any]:
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
BUILD_NUMBER = "2602260022"
|
||||
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
|
||||
BUILD_NUMBER = "0803262237"
|
||||
CHANGELOG = '2026-09-19|Initial minimal release source snapshot'
|
||||
|
||||
+397
-21
@@ -1,11 +1,262 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import logging
|
||||
import time
|
||||
import httpx
|
||||
|
||||
from ..logging_config import sanitize_headers, sanitize_value
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
from ..metrics import record_remote
|
||||
|
||||
|
||||
_SERVICE_NAMES = {
|
||||
"JellyseerrClient": "Seerr",
|
||||
"SonarrClient": "Sonarr",
|
||||
"RadarrClient": "Radarr",
|
||||
"BazarrClient": "Bazarr",
|
||||
"ProwlarrClient": "Prowlarr",
|
||||
"JellyfinClient": "Jellyfin",
|
||||
"QBittorrentClient": "qBittorrent",
|
||||
}
|
||||
|
||||
|
||||
def _result_items(result: Any, *keys: str) -> list[Any]:
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
if not isinstance(result, dict):
|
||||
return []
|
||||
for key in keys:
|
||||
value = result.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return []
|
||||
|
||||
|
||||
def _result_title(result: Any, payload: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
candidates = result if isinstance(result, list) else [result]
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
title = str(candidate.get("title") or candidate.get("name") or "").strip()
|
||||
if title:
|
||||
return title
|
||||
if isinstance(payload, dict):
|
||||
title = str(payload.get("title") or payload.get("name") or "").strip()
|
||||
if title:
|
||||
return title
|
||||
return None
|
||||
|
||||
|
||||
def _count_message(count: int, singular: str, plural: Optional[str] = None) -> str:
|
||||
noun = singular if count == 1 else (plural or f"{singular}s")
|
||||
return f"{count} {noun}"
|
||||
|
||||
|
||||
def _queue_result_message(service: str, result: Any) -> str:
|
||||
records = _result_items(result, "records", "items")
|
||||
total = result.get("totalRecords") if isinstance(result, dict) else None
|
||||
count = int(total) if isinstance(total, int) else len(records)
|
||||
if count == 0:
|
||||
return f"{service} has no matching downloads in its queue."
|
||||
first = next((item for item in records if isinstance(item, dict)), None)
|
||||
progress_text = ""
|
||||
if first:
|
||||
size = first.get("size")
|
||||
size_left = first.get("sizeleft")
|
||||
if isinstance(size, (int, float)) and size > 0 and isinstance(size_left, (int, float)):
|
||||
progress = max(0, min(100, round((1 - (size_left / size)) * 100)))
|
||||
progress_text = f" The first is {progress}% complete."
|
||||
return f"{service} found {_count_message(count, 'matching download')} in its queue.{progress_text}"
|
||||
|
||||
|
||||
def _command_name(payload: Optional[Dict[str, Any]]) -> str:
|
||||
raw_name = str((payload or {}).get("name") or "").strip()
|
||||
names = {
|
||||
"MoviesSearch": "movie search",
|
||||
"SeriesSearch": "series search",
|
||||
"EpisodeSearch": "episode search",
|
||||
"DownloadRelease": "release download",
|
||||
"RefreshMovie": "movie refresh",
|
||||
"RescanMovie": "movie rescan",
|
||||
"RefreshSeries": "series refresh",
|
||||
"RescanSeries": "series rescan",
|
||||
}
|
||||
return names.get(raw_name, "command")
|
||||
|
||||
|
||||
def _operation_result_message(
|
||||
service: str,
|
||||
method: str,
|
||||
path: str,
|
||||
result: Any,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
normalized_path = path.lower().split("?", 1)[0].rstrip("/")
|
||||
normalized_method = method.upper()
|
||||
title = _result_title(result, payload)
|
||||
title_text = f' "{title}"' if title else ""
|
||||
|
||||
if service == "Seerr":
|
||||
if normalized_path.endswith("/request") and normalized_method == "POST":
|
||||
request_id = result.get("id") if isinstance(result, dict) else None
|
||||
suffix = f" #{request_id}" if isinstance(request_id, int) else ""
|
||||
return f"Seerr created the request{suffix} and passed it into the collection workflow."
|
||||
if "/request/" in normalized_path and normalized_method == "GET":
|
||||
status_names = {1: "waiting for approval", 2: "approved", 3: "declined"}
|
||||
status = result.get("status") if isinstance(result, dict) else None
|
||||
status_text = status_names.get(status)
|
||||
return (
|
||||
f"Seerr found the request; it is currently {status_text}."
|
||||
if status_text
|
||||
else "Seerr found the request and returned its current status."
|
||||
)
|
||||
|
||||
if service in {"Radarr", "Sonarr"}:
|
||||
media_name = "movie" if service == "Radarr" else "series"
|
||||
media_path = "/movie" if service == "Radarr" else "/series"
|
||||
if "/queue" in normalized_path and normalized_method == "GET":
|
||||
return _queue_result_message(service, result)
|
||||
if "/command" in normalized_path and normalized_method == "POST":
|
||||
return f"{service} accepted the {_command_name(payload)} and put it in line to run. This does not mean a download has started."
|
||||
if "/release" in normalized_path:
|
||||
if normalized_method == "GET":
|
||||
count = len(_result_items(result, "records", "items"))
|
||||
return (
|
||||
f"{service} found {_count_message(count, 'download option')}."
|
||||
if count
|
||||
else f"{service} could not find a suitable download option."
|
||||
)
|
||||
return f"{service} accepted the selected release and sent it to the download client."
|
||||
if "/qualityprofile" in normalized_path and normalized_method == "GET":
|
||||
count = len(_result_items(result))
|
||||
return f"{service} returned {_count_message(count, 'download quality setting')}."
|
||||
if "/rootfolder" in normalized_path and normalized_method == "GET":
|
||||
count = len(_result_items(result))
|
||||
return f"{service} returned {_count_message(count, 'library folder')}."
|
||||
if "/indexer" in normalized_path and normalized_method == "GET":
|
||||
count = len(_result_items(result))
|
||||
return f"{service} reports {_count_message(count, 'configured search source')}."
|
||||
if service == "Sonarr" and "/episodefile" in normalized_path:
|
||||
if normalized_method == "DELETE":
|
||||
return "Sonarr removed the existing episode file so it can be replaced."
|
||||
count = len(_result_items(result))
|
||||
return f"Sonarr found {_count_message(count, 'downloaded episode file')}."
|
||||
if service == "Sonarr" and normalized_path.endswith("/episode/monitor") and normalized_method == "PUT":
|
||||
return "Sonarr marked the selected episodes as wanted."
|
||||
if service == "Sonarr" and "/episode" in normalized_path and normalized_method == "GET":
|
||||
episodes = _result_items(result)
|
||||
available = sum(1 for item in episodes if isinstance(item, dict) and item.get("hasFile") is True)
|
||||
return f"Sonarr reports {available} of {len(episodes)} episodes downloaded."
|
||||
if service == "Radarr" and "/moviefile/" in normalized_path and normalized_method == "DELETE":
|
||||
return "Radarr removed the existing movie file so it can be replaced."
|
||||
is_media_endpoint = normalized_path.endswith(media_path) or f"{media_path}/" in normalized_path
|
||||
if is_media_endpoint:
|
||||
if normalized_method == "GET":
|
||||
found = bool(result) if not isinstance(result, list) else len(result) > 0
|
||||
return (
|
||||
f"{service} found{title_text} in its library list."
|
||||
if found
|
||||
else f"This {media_name} is not currently in {service}."
|
||||
)
|
||||
if normalized_method == "POST":
|
||||
search_key = "searchForMovie" if service == "Radarr" else "searchForMissingEpisodes"
|
||||
search_requested = bool(((payload or {}).get("addOptions") or {}).get(search_key))
|
||||
search_text = " and started looking for a download" if search_requested else ""
|
||||
subject = title_text or f" the {media_name}"
|
||||
return f"{service} added{subject}{search_text}."
|
||||
if normalized_method == "PUT":
|
||||
return f"{service} saved the updated settings for{title_text or f' the {media_name}'}."
|
||||
if "/system/status" in normalized_path:
|
||||
version = str(result.get("version") or "").strip() if isinstance(result, dict) else ""
|
||||
return f"Connected to {service}{f' version {version}' if version else ''}."
|
||||
|
||||
if service == "Prowlarr":
|
||||
if "/health" in normalized_path:
|
||||
issues = _result_items(result)
|
||||
if not issues:
|
||||
return "The download search sources are working normally."
|
||||
first = next((item for item in issues if isinstance(item, dict)), {})
|
||||
detail = str(first.get("message") or first.get("source") or "").strip()
|
||||
suffix = f" First issue: {detail}" if detail else ""
|
||||
return f"Prowlarr reports {_count_message(len(issues), 'indexer issue')}.{suffix}"
|
||||
if "/search" in normalized_path:
|
||||
results = _result_items(result, "results", "records")
|
||||
return (
|
||||
f"Prowlarr found {_count_message(len(results), 'possible download')}."
|
||||
if results
|
||||
else "Prowlarr did not find any possible downloads."
|
||||
)
|
||||
|
||||
if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path:
|
||||
target = "movie" if "/movies/" in normalized_path else "selected episode"
|
||||
language = str((params or {}).get("language") or "the requested language").upper()
|
||||
return f"Bazarr accepted a fresh {language} subtitle search for the {target}."
|
||||
|
||||
if normalized_method == "GET":
|
||||
return f"{service} finished this check without reporting a problem."
|
||||
if normalized_method == "POST":
|
||||
return f"{service} received the request. Its result will be checked separately."
|
||||
if normalized_method == "PUT":
|
||||
return f"{service} saved the requested changes."
|
||||
if normalized_method == "DELETE":
|
||||
return f"{service} confirmed the item was removed."
|
||||
return f"{service} completed the request successfully."
|
||||
|
||||
|
||||
def _operation_error_message(service: str, status_code: Optional[int]) -> str:
|
||||
explanations = {
|
||||
400: "rejected the request because some details were invalid",
|
||||
401: "rejected Magent's login details",
|
||||
403: "refused permission for this action",
|
||||
404: "could not find the requested item",
|
||||
409: "reported a conflict, usually because the item already exists",
|
||||
422: "could not use the details Magent supplied",
|
||||
429: "is busy and asked Magent to try again later",
|
||||
500: "encountered an internal error while processing the request",
|
||||
502: "could not reach one of its own dependent services",
|
||||
503: "is temporarily unavailable",
|
||||
504: "did not finish before the request timed out",
|
||||
}
|
||||
explanation = explanations.get(status_code)
|
||||
if explanation:
|
||||
return f"{service} {explanation}."
|
||||
if status_code:
|
||||
return f"{service} could not complete the request (response code {status_code})."
|
||||
return f"Magent could not get a usable response from {service}."
|
||||
|
||||
|
||||
def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]:
|
||||
normalized_path = path.lower()
|
||||
normalized_method = method.upper()
|
||||
if service == "Seerr" and "/request/" in normalized_path and normalized_method == "GET":
|
||||
return "Reading the request from Seerr…", "Seerr returned the current request record"
|
||||
if service == "Radarr" and normalized_path.endswith("/movie") and normalized_method == "GET":
|
||||
return "Checking Radarr for the movie…", "Radarr returned the movie record"
|
||||
if service == "Sonarr" and normalized_path.endswith("/series") and normalized_method == "GET":
|
||||
return "Checking Sonarr for the series…", "Sonarr returned the series record"
|
||||
if service in {"Radarr", "Sonarr"} and "/queue" in normalized_path:
|
||||
return f"Checking {service}'s download queue…", f"{service} returned its queue state"
|
||||
if service == "Sonarr" and "/episode" in normalized_path:
|
||||
return "Checking episode availability in Sonarr…", "Sonarr returned episode availability"
|
||||
if service in {"Radarr", "Sonarr"} and "/release" in normalized_path:
|
||||
return f"Checking releases through {service}…", f"{service} returned release information"
|
||||
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
|
||||
if normalized_method == "GET":
|
||||
return f"Checking {service}'s search activity…", f"{service} returned its current activity"
|
||||
return f"Sending a command to {service}…", f"{service} accepted the command"
|
||||
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
|
||||
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
|
||||
if service == "Prowlarr" and "/health" in normalized_path:
|
||||
return "Checking whether the download search sources are working…", "Prowlarr returned its indexer health"
|
||||
return f"Contacting {service}…", f"{service} responded"
|
||||
|
||||
|
||||
class ApiClient:
|
||||
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
|
||||
self.base_url = base_url.rstrip("/") if base_url else None
|
||||
self.api_key = api_key
|
||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
@@ -13,31 +264,156 @@ class ApiClient:
|
||||
def headers(self) -> Dict[str, str]:
|
||||
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
||||
|
||||
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
|
||||
if response is None:
|
||||
return None
|
||||
try:
|
||||
payload = sanitize_value(response.json())
|
||||
except ValueError:
|
||||
payload = sanitize_value(response.text)
|
||||
if isinstance(payload, str) and len(payload) > 500:
|
||||
return f"{payload[:500]}..."
|
||||
return payload
|
||||
|
||||
async def _send_request(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Dict[str, str],
|
||||
params: Optional[Dict[str, Any]],
|
||||
payload: Optional[Dict[str, Any]],
|
||||
) -> httpx.Response:
|
||||
return await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
|
||||
return None
|
||||
url = f"{self.base_url}{path}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=self.headers(), params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
started_at = time.perf_counter()
|
||||
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
|
||||
active_message, _ = _operation_messages(service_name, method, path)
|
||||
operation_event_id = start_remote_call(service_name, active_message)
|
||||
metric_status = 'error'
|
||||
self.logger.debug(
|
||||
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
||||
method,
|
||||
url,
|
||||
sanitize_value(params),
|
||||
sanitize_value(payload),
|
||||
sanitize_headers(self.headers()),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||||
response = await self._send_request(
|
||||
client,
|
||||
method,
|
||||
url,
|
||||
headers=self.headers(),
|
||||
params=params,
|
||||
payload=payload,
|
||||
)
|
||||
metric_status = str(response.status_code)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
self.logger.debug(
|
||||
"outbound request completed method=%s url=%s status=%s duration_ms=%s",
|
||||
method,
|
||||
url,
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
)
|
||||
result = response.json() if response.content else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=_operation_result_message(
|
||||
service_name,
|
||||
method,
|
||||
path,
|
||||
result,
|
||||
params=params,
|
||||
payload=payload,
|
||||
),
|
||||
)
|
||||
return result
|
||||
except httpx.HTTPStatusError as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
response = exc.response
|
||||
status = response.status_code if response is not None else "unknown"
|
||||
log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
|
||||
log_fn(
|
||||
"outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
|
||||
method,
|
||||
url,
|
||||
status,
|
||||
duration_ms,
|
||||
self._response_summary(response),
|
||||
)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status if isinstance(status, int) else None,
|
||||
message=_operation_error_message(
|
||||
service_name,
|
||||
status if isinstance(status, int) else None,
|
||||
),
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
self.logger.exception(
|
||||
"outbound request failed method=%s url=%s duration_ms=%s",
|
||||
method,
|
||||
url,
|
||||
duration_ms,
|
||||
)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
message=_operation_error_message(service_name, None),
|
||||
)
|
||||
raise
|
||||
|
||||
finally:
|
||||
record_remote(service_name, method, metric_status, time.perf_counter() - started_at)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
path: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> Optional[Any]:
|
||||
return await self._request(
|
||||
"GET", path, params=params, timeout_seconds=timeout_seconds
|
||||
)
|
||||
|
||||
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}{path}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=self.headers(), json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return await self._request("POST", path, payload=payload)
|
||||
|
||||
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}{path}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.put(url, headers=self.headers(), json=payload)
|
||||
response.raise_for_status()
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
return await self._request("PUT", path, payload=payload)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
path: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Any]:
|
||||
return await self._request("DELETE", path, params=params)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class BazarrClient(ApiClient):
|
||||
async def get_system_status(self) -> Optional[Any]:
|
||||
return await self._request("GET", "/api/system/status")
|
||||
|
||||
async def search_movie_subtitles(
|
||||
self,
|
||||
radarr_id: int,
|
||||
*,
|
||||
language: str,
|
||||
forced: bool = False,
|
||||
) -> Optional[Any]:
|
||||
return await self._request(
|
||||
"PATCH",
|
||||
"/api/movies/subtitles",
|
||||
params={
|
||||
"radarrid": radarr_id,
|
||||
"language": language,
|
||||
"forced": str(forced).lower(),
|
||||
"hi": "false",
|
||||
},
|
||||
timeout_seconds=90.0,
|
||||
)
|
||||
|
||||
async def search_episode_subtitles(
|
||||
self,
|
||||
series_id: int,
|
||||
episode_id: int,
|
||||
*,
|
||||
language: str,
|
||||
forced: bool = False,
|
||||
) -> Optional[Any]:
|
||||
return await self._request(
|
||||
"PATCH",
|
||||
"/api/episodes/subtitles",
|
||||
params={
|
||||
"seriesid": series_id,
|
||||
"episodeid": episode_id,
|
||||
"language": language,
|
||||
"forced": str(forced).lower(),
|
||||
"hi": "false",
|
||||
},
|
||||
timeout_seconds=90.0,
|
||||
)
|
||||
+240
-12
@@ -1,6 +1,24 @@
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
from .base import ApiClient
|
||||
from .base import ApiClient, _operation_error_message
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
def _availability_message(result: Any) -> str:
|
||||
if not isinstance(result, dict):
|
||||
return "Jellyfin did not return any matching library items."
|
||||
total = result.get("TotalRecordCount")
|
||||
items = result.get("Items")
|
||||
available = (
|
||||
(isinstance(total, int) and total > 0)
|
||||
or (isinstance(items, list) and len(items) > 0)
|
||||
)
|
||||
return (
|
||||
"Jellyfin returned possible matches. Magent still needs to check the exact title and file."
|
||||
if available
|
||||
else "Jellyfin did not find this title in its library search."
|
||||
)
|
||||
|
||||
|
||||
class JellyfinClient(ApiClient):
|
||||
@@ -10,61 +28,271 @@ class JellyfinClient(ApiClient):
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.api_key)
|
||||
|
||||
def _emby_headers(self) -> Dict[str, str]:
|
||||
return {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||
|
||||
@staticmethod
|
||||
def _extract_user_id(payload: Any) -> Optional[str]:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
|
||||
if not isinstance(candidate, dict):
|
||||
return None
|
||||
for key in ("Id", "id", "UserId", "userId"):
|
||||
value = candidate.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (str, int)):
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
async def get_users(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}/Users"
|
||||
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/{user_id}"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
users = await self.get_users()
|
||||
if not isinstance(users, list):
|
||||
return None
|
||||
target = username.strip().lower()
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
name = str(user.get("Name") or "").strip().lower()
|
||||
if name and name == target:
|
||||
return user
|
||||
return None
|
||||
|
||||
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/AuthenticateByName"
|
||||
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||
headers = self._emby_headers()
|
||||
payload = {"Username": username, "Pw": password}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/New"
|
||||
headers = self._emby_headers()
|
||||
payload = {"Name": username}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
async def set_user_password(self, user_id: str, password: str) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
headers = self._emby_headers()
|
||||
payloads = [
|
||||
{"CurrentPw": "", "NewPw": password},
|
||||
{"CurrentPwd": "", "NewPw": password},
|
||||
{"CurrentPw": "", "NewPw": password, "ResetPassword": False},
|
||||
{"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
|
||||
{"NewPw": password, "ResetPassword": False},
|
||||
]
|
||||
paths = [
|
||||
f"/Users/{user_id}/Password",
|
||||
f"/Users/{user_id}/EasyPassword",
|
||||
]
|
||||
last_error: Exception | None = None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for path in paths:
|
||||
url = f"{self.base_url}{path}"
|
||||
for payload in payloads:
|
||||
try:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return
|
||||
except httpx.HTTPStatusError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
user = await self.get_user(user_id)
|
||||
if not isinstance(user, dict):
|
||||
raise RuntimeError("Jellyfin user details not available")
|
||||
policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
|
||||
payload = {**policy, "IsDisabled": bool(disabled)}
|
||||
url = f"{self.base_url}/Users/{user_id}/Policy"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
async def delete_user(self, user_id: str) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/{user_id}"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.delete(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
created = await self.create_user(username)
|
||||
user_id = self._extract_user_id(created)
|
||||
if not user_id:
|
||||
users = await self.get_users()
|
||||
if isinstance(users, list):
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
name = str(user.get("Name") or "").strip()
|
||||
if name.lower() == username.strip().lower():
|
||||
created = user
|
||||
user_id = self._extract_user_id(user)
|
||||
break
|
||||
if not user_id:
|
||||
raise RuntimeError("Jellyfin user created but user ID was not returned")
|
||||
await self.set_user_password(user_id, password)
|
||||
return created
|
||||
|
||||
async def search_items(
|
||||
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
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,ProviderIds,OriginalTitle,SortName",
|
||||
"Limit": limit,
|
||||
}
|
||||
headers = {"X-Emby-Token": self.api_key}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
headers = self._emby_headers()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
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,
|
||||
status_code=response.status_code,
|
||||
message=_availability_message(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("Jellyfin", status_code),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_series_episodes(self, series_id: str) -> list[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key or not str(series_id).strip():
|
||||
return []
|
||||
url = f"{self.base_url}/Items"
|
||||
params = {
|
||||
"ParentId": str(series_id).strip(),
|
||||
"IncludeItemTypes": "Episode",
|
||||
"Recursive": "true",
|
||||
"Fields": "Path,ProviderIds,MediaSources",
|
||||
"Limit": 10000,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.get(url, headers=self._emby_headers(), params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
items = payload.get("Items") or payload.get("items") or []
|
||||
return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
|
||||
|
||||
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/System/Info"
|
||||
headers = {"X-Emby-Token": self.api_key}
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_sessions(self) -> Optional[list[Dict[str, Any]]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Sessions"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload if isinstance(payload, list) else []
|
||||
|
||||
async def refresh_library(self, recursive: bool = True) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
|
||||
url = f"{self.base_url}/Library/Refresh"
|
||||
headers = {"X-Emby-Token": self.api_key}
|
||||
headers = self._emby_headers()
|
||||
params = {"Recursive": "true" if recursive else "false"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message="Jellyfin accepted the library refresh and is scanning for new media.",
|
||||
)
|
||||
except Exception as exc:
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("Jellyfin", status_code),
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
import httpx
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellyseerrClient(ApiClient):
|
||||
async def _send_request(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Dict[str, str],
|
||||
params: Optional[Dict[str, Any]],
|
||||
payload: Optional[Dict[str, Any]],
|
||||
) -> httpx.Response:
|
||||
request_headers = dict(headers)
|
||||
if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url:
|
||||
# Seerr's optional CSRF protection also applies to API-key writes.
|
||||
# Seed its secret/token cookie pair, then echo the readable token in
|
||||
# the header Seerr's own web client uses.
|
||||
csrf_response = await client.get(
|
||||
f"{self.base_url}/api/v1/auth/me",
|
||||
headers=self.headers(),
|
||||
)
|
||||
csrf_response.raise_for_status()
|
||||
csrf_token = client.cookies.get("XSRF-TOKEN")
|
||||
if csrf_token:
|
||||
request_headers["XSRF-TOKEN"] = unquote(csrf_token)
|
||||
parsed_base = urlsplit(self.base_url)
|
||||
request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}"
|
||||
return await super()._send_request(
|
||||
client,
|
||||
method,
|
||||
url,
|
||||
headers=request_headers,
|
||||
params=params,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
async def get_status(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v1/status")
|
||||
|
||||
@@ -18,9 +54,6 @@ class JellyseerrClient(ApiClient):
|
||||
},
|
||||
)
|
||||
|
||||
async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/media/{media_id}")
|
||||
|
||||
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/movie/{tmdb_id}")
|
||||
|
||||
@@ -28,13 +61,42 @@ class JellyseerrClient(ApiClient):
|
||||
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
||||
|
||||
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(
|
||||
"/api/v1/search",
|
||||
params={
|
||||
"query": query,
|
||||
"page": page,
|
||||
},
|
||||
)
|
||||
# Seerr rejects the `+` encoding that standard query builders use for
|
||||
# spaces. Build this query explicitly so multi-word titles are sent as
|
||||
# percent-encoded values.
|
||||
encoded_query = quote(query, safe="")
|
||||
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
|
||||
|
||||
async def get_service_settings(self, media_type: str) -> Optional[Any]:
|
||||
service = "sonarr" if media_type == "tv" else "radarr"
|
||||
return await self.get(f"/api/v1/settings/{service}")
|
||||
|
||||
async def create_request(
|
||||
self,
|
||||
*,
|
||||
media_type: str,
|
||||
media_id: int,
|
||||
seasons: Optional[list[int]] = None,
|
||||
is_4k: Optional[bool] = None,
|
||||
server_id: Optional[int] = None,
|
||||
profile_id: Optional[int] = None,
|
||||
root_folder: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {
|
||||
"mediaType": media_type,
|
||||
"mediaId": media_id,
|
||||
}
|
||||
if isinstance(seasons, list) and seasons:
|
||||
payload["seasons"] = seasons
|
||||
if isinstance(is_4k, bool):
|
||||
payload["is4k"] = is_4k
|
||||
if isinstance(server_id, int):
|
||||
payload["serverId"] = server_id
|
||||
if isinstance(profile_id, int):
|
||||
payload["profileId"] = profile_id
|
||||
if isinstance(root_folder, str) and root_folder.strip():
|
||||
payload["rootFolder"] = root_folder.strip()
|
||||
return await self.post("/api/v1/request", payload=payload)
|
||||
|
||||
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(
|
||||
@@ -44,3 +106,20 @@ class JellyseerrClient(ApiClient):
|
||||
"skip": skip,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/user/{user_id}")
|
||||
|
||||
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.delete(f"/api/v1/user/{user_id}")
|
||||
|
||||
async def login_local(self, email: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
payload = {"email": email, "password": password}
|
||||
try:
|
||||
return await self.post("/api/v1/auth/local", payload=payload)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
# Backward compatibility for older Seerr/Overseerr deployments
|
||||
# that still expose /auth/login instead of /auth/local.
|
||||
if exc.response is not None and exc.response.status_code in {404, 405}:
|
||||
return await self.post("/api/v1/auth/login", payload=payload)
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Jellystat API adapter. Credentials and raw history never leave the backend."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellystatError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HistoryLimitError(JellystatError):
|
||||
pass
|
||||
|
||||
|
||||
def same_user_id(left, right) -> bool:
|
||||
return bool(left and right) and str(left).replace("-", "").lower() == str(right).replace("-", "").lower()
|
||||
|
||||
|
||||
class JellystatClient(ApiClient):
|
||||
PAGE_SIZE = 200
|
||||
MAX_PAGES = 50
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.api_key)
|
||||
|
||||
async def _read(self, client: httpx.AsyncClient, method: str, path: str, **kwargs):
|
||||
try:
|
||||
response = await client.request(method, f"{self.base_url}{path}",
|
||||
headers={"x-api-token": self.api_key}, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise JellystatError("Jellystat did not return a valid response") from exc
|
||||
|
||||
async def test_connection(self) -> dict:
|
||||
# This protected endpoint confirms API authentication without returning user data.
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
result = await self._read(client, "GET", "/api/getLibraries")
|
||||
if not isinstance(result, list):
|
||||
raise JellystatError("Jellystat returned an unexpected library response")
|
||||
return {"connected": True}
|
||||
|
||||
async def check_user_ids(self, user_ids: list[str]) -> dict:
|
||||
"""Read metadata for known identities; never scan everyone's playback history."""
|
||||
if not self.configured():
|
||||
return {user_id: {"state": "not_configured"} for user_id in user_ids}
|
||||
results = {user_id: {"state": "unavailable"} for user_id in user_ids}
|
||||
semaphore = asyncio.Semaphore(6)
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
async def check(user_id):
|
||||
if not re.fullmatch(r"[a-f0-9]{32}", user_id):
|
||||
return
|
||||
async with semaphore:
|
||||
try:
|
||||
response = await client.post(f"{self.base_url}/api/getUserDetails",
|
||||
headers={"x-api-token": self.api_key}, json={"userid": user_id})
|
||||
if response.status_code == 404 or (response.status_code == 200 and not response.content.strip()):
|
||||
results[user_id] = {"state": "missing"}
|
||||
return
|
||||
response.raise_for_status()
|
||||
row = response.json()
|
||||
if row is None:
|
||||
results[user_id] = {"state": "missing"}
|
||||
elif isinstance(row, dict) and same_user_id(row.get("Id"), user_id):
|
||||
results[user_id] = {"state": "matched", "id": user_id, "name": str(row.get("Name") or "")[:200]}
|
||||
except (httpx.HTTPError, ValueError):
|
||||
pass
|
||||
try:
|
||||
async with asyncio.timeout(25):
|
||||
await asyncio.gather(*(check(user_id) for user_id in user_ids))
|
||||
except TimeoutError:
|
||||
pass
|
||||
return results
|
||||
|
||||
async def get_user_history(self, user_id: str, start: datetime, end: datetime) -> tuple[list, list]:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", user_id):
|
||||
raise JellystatError("Invalid linked Jellyfin identity")
|
||||
# Only fixed, user-scoped endpoints are used. Never pass browser search/filters through.
|
||||
filters = json.dumps([{"field": "ActivityDateInserted", "min": start.isoformat(), "max": end.isoformat()}])
|
||||
try:
|
||||
async with asyncio.timeout(30):
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
libraries = await self._read(client, "GET", "/api/getLibraries")
|
||||
if not isinstance(libraries, list) or any(not isinstance(row, dict) for row in libraries):
|
||||
raise JellystatError("Jellystat returned an unexpected library response")
|
||||
history = []
|
||||
for page in range(1, self.MAX_PAGES + 1):
|
||||
payload = await self._read(client, "POST", "/api/getUserHistory",
|
||||
json={"userid": user_id}, params={"page": page, "size": self.PAGE_SIZE,
|
||||
"sort": "ActivityDateInserted", "desc": "true", "filters": filters})
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
|
||||
raise JellystatError("Jellystat returned an unexpected history response")
|
||||
rows = payload["results"]
|
||||
try:
|
||||
pages = int(payload["pages"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise JellystatError("Jellystat did not return history pagination") from exc
|
||||
if pages < 0 or (pages == 0 and rows) or len(rows) > self.PAGE_SIZE:
|
||||
raise JellystatError("Jellystat returned invalid history pagination")
|
||||
if pages > self.MAX_PAGES:
|
||||
raise HistoryLimitError("Select a shorter period to view this history")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or not same_user_id(row.get("UserId"), user_id):
|
||||
raise JellystatError("Jellystat returned history for an unexpected account")
|
||||
history.extend(rows)
|
||||
if page >= pages:
|
||||
return history, libraries
|
||||
if not rows:
|
||||
raise JellystatError("Jellystat returned incomplete history")
|
||||
except TimeoutError as exc:
|
||||
raise JellystatError("Jellystat took too long to return history") from exc
|
||||
raise HistoryLimitError("Select a shorter period to view this history")
|
||||
@@ -1,7 +1,64 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import logging
|
||||
from .base import ApiClient
|
||||
from .base import ApiClient, _operation_error_message
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
def _torrent_state_text(state: Any) -> str:
|
||||
normalized = str(state or "").strip().lower()
|
||||
if normalized in {"uploading", "stalledup", "forcedup", "queuedup", "pausedup", "stoppedup", "completed"}:
|
||||
return "finished"
|
||||
if "pause" in normalized or normalized == "stoppeddl":
|
||||
return "paused"
|
||||
if "stall" in normalized:
|
||||
return "waiting for data"
|
||||
if normalized.startswith("queued"):
|
||||
return "waiting in the queue"
|
||||
if normalized == "metadl":
|
||||
return "getting the download details"
|
||||
if normalized in {"checkingdl", "checkingup", "checkingresumedata"}:
|
||||
return "checking the downloaded files"
|
||||
if "downloading" in normalized or normalized in {"forcedl", "forceddl"}:
|
||||
return "downloading"
|
||||
if "upload" in normalized:
|
||||
return "downloaded and sharing with others"
|
||||
if normalized in {"completed", "missingfiles"}:
|
||||
return "finished" if normalized == "completed" else "missing files"
|
||||
if "error" in normalized:
|
||||
return "unable to continue"
|
||||
return "present"
|
||||
|
||||
|
||||
def _torrent_result_message(result: Any) -> str:
|
||||
torrents = result if isinstance(result, list) else []
|
||||
if not torrents:
|
||||
return "qBittorrent found no matching downloads."
|
||||
first = next((item for item in torrents if isinstance(item, dict)), {})
|
||||
if len(torrents) == 1:
|
||||
progress = first.get("progress")
|
||||
progress_text = (
|
||||
f" — {max(0, min(100, round(progress * 100)))}% complete"
|
||||
if isinstance(progress, (int, float))
|
||||
else ""
|
||||
)
|
||||
state_text = _torrent_state_text(first.get("state"))
|
||||
return f'{"Downloading" if state_text == "downloading" else "The download is " + state_text}{progress_text}.'
|
||||
active = sum(
|
||||
1
|
||||
for item in torrents
|
||||
if isinstance(item, dict) and _torrent_state_text(item.get("state")) == "downloading"
|
||||
)
|
||||
return f"qBittorrent found {len(torrents)} matching downloads; {active} are actively downloading."
|
||||
|
||||
|
||||
def _torrent_action_message(path: str) -> str:
|
||||
normalized_path = path.lower()
|
||||
if normalized_path.endswith("/resume") or normalized_path.endswith("/start"):
|
||||
return "qBittorrent accepted the request to resume the download."
|
||||
if normalized_path.endswith("/add"):
|
||||
return "qBittorrent accepted the release and added it to the download queue."
|
||||
return "qBittorrent accepted the requested download action."
|
||||
|
||||
|
||||
class QBittorrentClient(ApiClient):
|
||||
@@ -23,34 +80,100 @@ class QBittorrentClient(ApiClient):
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.text.strip().lower() != "ok.":
|
||||
text = response.text.strip().lower()
|
||||
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
|
||||
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
|
||||
raise RuntimeError("qBittorrent login failed")
|
||||
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=_torrent_result_message(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("qBittorrent", status_code),
|
||||
)
|
||||
raise
|
||||
|
||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.text.strip()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.text.strip()
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"Connected to qBittorrent{f' version {result}' if result else ''}.",
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("qBittorrent", status_code),
|
||||
)
|
||||
raise
|
||||
|
||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||
if not self.base_url:
|
||||
return None
|
||||
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()
|
||||
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()
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=_torrent_action_message(path),
|
||||
)
|
||||
except Exception as exc:
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("qBittorrent", status_code),
|
||||
)
|
||||
raise
|
||||
|
||||
async def is_webui_reachable(self) -> bool:
|
||||
if not self.base_url:
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get(self.base_url)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
async def get_torrents(self) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info")
|
||||
@@ -61,6 +184,9 @@ class QBittorrentClient(ApiClient):
|
||||
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info", params={"category": category})
|
||||
|
||||
async def get_torrents_by_tag(self, tag: str) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info", params={"tag": tag})
|
||||
|
||||
async def get_app_version(self) -> Optional[Any]:
|
||||
return await self._get_text("/api/v2/app/version")
|
||||
|
||||
@@ -73,7 +199,9 @@ class QBittorrentClient(ApiClient):
|
||||
return
|
||||
raise
|
||||
|
||||
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
|
||||
async def add_torrent_url(
|
||||
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
|
||||
) -> None:
|
||||
url_host = None
|
||||
if isinstance(url, str) and "://" in url:
|
||||
url_host = url.split("://", 1)[-1].split("/", 1)[0]
|
||||
@@ -85,4 +213,6 @@ class QBittorrentClient(ApiClient):
|
||||
data: Dict[str, Any] = {"urls": url}
|
||||
if category:
|
||||
data["category"] = category
|
||||
if tags:
|
||||
data["tags"] = tags
|
||||
await self._post_form("/api/v2/torrents/add", data=data)
|
||||
|
||||
@@ -9,6 +9,10 @@ class RadarrClient(ApiClient):
|
||||
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
|
||||
|
||||
async def lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id})
|
||||
return result if isinstance(result, dict) else None
|
||||
|
||||
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v3/movie/{movie_id}")
|
||||
|
||||
@@ -22,7 +26,12 @@ 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(
|
||||
"/api/v3/release", params={"movieId": movie_id}, timeout_seconds=90.0
|
||||
)
|
||||
|
||||
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/indexer")
|
||||
@@ -30,6 +39,21 @@ class RadarrClient(ApiClient):
|
||||
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
|
||||
|
||||
async def monitor_movie(
|
||||
self, movie_id: int, monitored: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
movie = await self.get_movie(movie_id)
|
||||
if not isinstance(movie, dict):
|
||||
raise ValueError("Radarr did not return the movie before updating its monitored state")
|
||||
movie["monitored"] = monitored
|
||||
return await self.update_movie(movie)
|
||||
|
||||
async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
|
||||
return await self.delete(
|
||||
f"/api/v3/moviefile/{movie_file_id}",
|
||||
params={"deleteFromClient": "true"},
|
||||
)
|
||||
|
||||
async def add_movie(
|
||||
self,
|
||||
tmdb_id: int,
|
||||
@@ -37,9 +61,15 @@ class RadarrClient(ApiClient):
|
||||
root_folder: str,
|
||||
monitored: bool = True,
|
||||
search_for_movie: bool = True,
|
||||
title: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
lookup = await self.lookup_movie_by_tmdb_id(tmdb_id)
|
||||
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
|
||||
if not resolved_title:
|
||||
raise ValueError("Radarr could not resolve a title for this TMDB ID")
|
||||
payload = {
|
||||
"tmdbId": tmdb_id,
|
||||
"title": resolved_title,
|
||||
"qualityProfileId": quality_profile_id,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": monitored,
|
||||
|
||||
@@ -9,6 +9,20 @@ class SonarrClient(ApiClient):
|
||||
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
|
||||
|
||||
async def lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"})
|
||||
if not isinstance(result, list):
|
||||
return None
|
||||
for item in result:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
if int(item.get("tvdbId")) == tvdb_id:
|
||||
return item
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return next((item for item in result if isinstance(item, dict)), None)
|
||||
|
||||
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v3/series/{series_id}")
|
||||
|
||||
@@ -19,7 +33,22 @@ class SonarrClient(ApiClient):
|
||||
return await self.get("/api/v3/qualityprofile")
|
||||
|
||||
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/queue", params={"seriesId": series_id})
|
||||
records = []
|
||||
page = 1
|
||||
while True:
|
||||
result = await self.get("/api/v3/queue", params={
|
||||
"seriesIds": series_id, "includeEpisode": "true",
|
||||
"page": page, "pageSize": 100,
|
||||
})
|
||||
if not isinstance(result, dict) or not isinstance(result.get("records"), list):
|
||||
raise ValueError("Sonarr returned an invalid queue")
|
||||
batch = result["records"]
|
||||
records.extend(batch)
|
||||
if not batch or len(records) >= int(result.get("totalRecords", len(records))):
|
||||
return {**result, "records": records, "totalRecords": len(records)}
|
||||
page += 1
|
||||
if page > 100:
|
||||
raise ValueError("Sonarr queue exceeded the safe paging limit")
|
||||
|
||||
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/indexer")
|
||||
@@ -27,12 +56,39 @@ class SonarrClient(ApiClient):
|
||||
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/episode", params={"seriesId": series_id})
|
||||
|
||||
async def get_episode_files(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/episodefile", params={"seriesId": series_id})
|
||||
|
||||
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
|
||||
return await self.get(
|
||||
"/api/v3/release",
|
||||
params={"seriesId": series_id, "seasonNumber": season_number},
|
||||
timeout_seconds=90.0,
|
||||
)
|
||||
|
||||
async def search_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})
|
||||
|
||||
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
|
||||
|
||||
async def monitor_episodes(
|
||||
self, episode_ids: list[int], monitored: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return await self.put(
|
||||
"/api/v3/episode/monitor",
|
||||
payload={"episodeIds": episode_ids, "monitored": monitored},
|
||||
)
|
||||
|
||||
async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
|
||||
return await self.delete(
|
||||
f"/api/v3/episodefile/{episode_file_id}",
|
||||
params={"deleteFromClient": "true"},
|
||||
)
|
||||
|
||||
async def add_series(
|
||||
self,
|
||||
tvdb_id: int,
|
||||
@@ -42,16 +98,19 @@ class SonarrClient(ApiClient):
|
||||
title: Optional[str] = None,
|
||||
search_missing: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
lookup = await self.lookup_series_by_tvdb_id(tvdb_id)
|
||||
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
|
||||
if not resolved_title:
|
||||
raise ValueError("Sonarr could not resolve a title for this TVDB ID")
|
||||
payload = {
|
||||
"tvdbId": tvdb_id,
|
||||
"title": resolved_title,
|
||||
"qualityProfileId": quality_profile_id,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": monitored,
|
||||
"seasonFolder": True,
|
||||
"addOptions": {"searchForMissingEpisodes": search_missing},
|
||||
}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
return await self.post("/api/v3/series", payload=payload)
|
||||
|
||||
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
+248
-4
@@ -1,23 +1,91 @@
|
||||
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"
|
||||
cors_allow_origin: str = "http://localhost:3000"
|
||||
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
|
||||
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
|
||||
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
||||
sqlite_journal_mode: str = Field(
|
||||
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
||||
)
|
||||
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
|
||||
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")
|
||||
)
|
||||
auth_rate_limit_max_attempts_ip: int = Field(
|
||||
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
||||
)
|
||||
auth_rate_limit_max_attempts_user: int = Field(
|
||||
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
|
||||
)
|
||||
password_reset_rate_limit_window_seconds: int = Field(
|
||||
default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
|
||||
)
|
||||
password_reset_rate_limit_max_attempts_ip: int = Field(
|
||||
default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
||||
)
|
||||
password_reset_rate_limit_max_attempts_identifier: int = Field(
|
||||
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
|
||||
)
|
||||
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
||||
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||
setup_token: str = Field(default="", validation_alias=AliasChoices("SETUP_TOKEN"))
|
||||
auth_cookie_name: str = Field(
|
||||
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
||||
)
|
||||
auth_cookie_secure: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
|
||||
)
|
||||
auth_cookie_samesite: str = Field(
|
||||
default="strict", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
|
||||
)
|
||||
auth_cookie_domain: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
|
||||
)
|
||||
auth_state_cookie_name: str = Field(
|
||||
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
|
||||
)
|
||||
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
||||
log_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")
|
||||
)
|
||||
log_file_backup_count: int = Field(
|
||||
default=10, validation_alias=AliasChoices("LOG_FILE_BACKUP_COUNT")
|
||||
)
|
||||
log_http_client_level: str = Field(
|
||||
default="INFO", validation_alias=AliasChoices("LOG_HTTP_CLIENT_LEVEL")
|
||||
)
|
||||
log_background_sync_level: str = Field(
|
||||
default="INFO", validation_alias=AliasChoices("LOG_BACKGROUND_SYNC_LEVEL")
|
||||
)
|
||||
requests_sync_ttl_minutes: int = Field(
|
||||
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")
|
||||
)
|
||||
@@ -36,6 +104,15 @@ class Settings(BaseSettings):
|
||||
requests_data_source: str = Field(
|
||||
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
|
||||
)
|
||||
issue_confirmation_contact_attempts: int = Field(
|
||||
default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS")
|
||||
)
|
||||
issue_confirmation_interval_value: int = Field(
|
||||
default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE")
|
||||
)
|
||||
issue_confirmation_interval_unit: str = Field(
|
||||
default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT")
|
||||
)
|
||||
artwork_cache_mode: str = Field(
|
||||
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
||||
)
|
||||
@@ -49,14 +126,171 @@ 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")
|
||||
)
|
||||
site_login_show_local_login: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
|
||||
)
|
||||
site_login_show_forgot_password: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
|
||||
)
|
||||
site_login_show_signup_link: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
|
||||
)
|
||||
site_nav_show_requests: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
|
||||
)
|
||||
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
||||
|
||||
magent_application_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
|
||||
)
|
||||
magent_application_port: int = Field(
|
||||
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
|
||||
)
|
||||
magent_api_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
|
||||
)
|
||||
magent_api_port: int = Field(
|
||||
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
|
||||
)
|
||||
magent_bind_host: str = Field(
|
||||
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
|
||||
)
|
||||
magent_proxy_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
|
||||
)
|
||||
magent_proxy_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
|
||||
)
|
||||
magent_proxy_trust_forwarded_headers: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
|
||||
)
|
||||
magent_proxy_trusted_proxies: str = Field(
|
||||
default="127.0.0.1,::1",
|
||||
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
|
||||
)
|
||||
magent_proxy_forwarded_prefix: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
|
||||
)
|
||||
magent_ssl_bind_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
|
||||
)
|
||||
magent_ssl_certificate_path: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
|
||||
)
|
||||
magent_ssl_private_key_path: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
|
||||
)
|
||||
magent_ssl_certificate_pem: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
|
||||
)
|
||||
magent_ssl_private_key_pem: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
|
||||
)
|
||||
|
||||
magent_notify_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
|
||||
)
|
||||
magent_notify_email_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
|
||||
)
|
||||
magent_notify_email_smtp_host: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
|
||||
)
|
||||
magent_notify_email_smtp_port: int = Field(
|
||||
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
|
||||
)
|
||||
magent_notify_email_smtp_username: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
|
||||
)
|
||||
magent_notify_email_smtp_password: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
|
||||
)
|
||||
magent_notify_email_from_address: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
|
||||
)
|
||||
magent_notify_email_from_name: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
|
||||
)
|
||||
magent_notify_email_use_tls: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
|
||||
)
|
||||
magent_notify_email_use_ssl: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
|
||||
)
|
||||
|
||||
magent_notify_discord_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
|
||||
)
|
||||
magent_notify_discord_webhook_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
|
||||
)
|
||||
|
||||
magent_notify_telegram_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
|
||||
)
|
||||
magent_notify_telegram_bot_token: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
|
||||
)
|
||||
magent_notify_telegram_chat_id: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
|
||||
)
|
||||
|
||||
magent_notify_push_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
|
||||
)
|
||||
magent_notify_push_provider: Optional[str] = Field(
|
||||
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
|
||||
)
|
||||
magent_notify_push_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
|
||||
)
|
||||
magent_notify_push_topic: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
|
||||
)
|
||||
magent_notify_push_token: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
|
||||
)
|
||||
magent_notify_push_user_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
|
||||
)
|
||||
magent_notify_push_device: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
|
||||
)
|
||||
|
||||
magent_notify_webhook_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
|
||||
)
|
||||
magent_notify_webhook_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
|
||||
)
|
||||
magent_allow_private_notification_targets: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
|
||||
)
|
||||
|
||||
jellyseerr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
||||
)
|
||||
jellyseerr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
|
||||
)
|
||||
jellystat_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSTAT_URL", "JELLYSTAT_BASE_URL")
|
||||
)
|
||||
jellystat_api_key: Optional[str] = Field(default=None, validation_alias="JELLYSTAT_API_KEY")
|
||||
|
||||
jellyfin_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
|
||||
)
|
||||
@@ -104,6 +338,16 @@ class Settings(BaseSettings):
|
||||
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
|
||||
)
|
||||
|
||||
bazarr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("BAZARR_URL", "BAZARR_BASE_URL")
|
||||
)
|
||||
bazarr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("BAZARR_API_KEY", "BAZARR_KEY")
|
||||
)
|
||||
bazarr_default_language: str = Field(
|
||||
default="en", validation_alias=AliasChoices("BAZARR_DEFAULT_LANGUAGE")
|
||||
)
|
||||
|
||||
prowlarr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
|
||||
)
|
||||
@@ -122,7 +366,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
discord_webhook_url: Optional[str] = Field(
|
||||
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
|
||||
default=None,
|
||||
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Persistent secrets for fresh image-only container installations.
|
||||
|
||||
Runs before importing application settings. Existing environment-managed
|
||||
deployments are unchanged. Secrets are never printed during normal startup.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from contextlib import closing
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .installation_origin import normalize_application_origin
|
||||
|
||||
|
||||
DATA_DIRECTORY = Path("/app/data")
|
||||
STATE_FILENAME = "bootstrap-secrets.json"
|
||||
SECRET_NAMES = ("JWT_SECRET", "SETTINGS_ENCRYPTION_KEY", "SETUP_TOKEN")
|
||||
MAX_STATE_BYTES = 4096
|
||||
|
||||
|
||||
class BootstrapError(ValueError):
|
||||
"""An operator-actionable error that never includes a secret value."""
|
||||
|
||||
|
||||
def managed_mode(environment: dict) -> bool:
|
||||
value = environment.get("MAGENT_MANAGED_SECRETS", "false").strip().lower()
|
||||
if value == "auto":
|
||||
# Existing explicitly keyed installations retain their environment and
|
||||
# JWT-derived encryption behaviour. Fresh image-only installs opt in.
|
||||
return not bool(environment.get("JWT_SECRET", "").strip())
|
||||
if value not in {"true", "false", "1", "0", "yes", "no", ""}:
|
||||
raise BootstrapError("MAGENT_MANAGED_SECRETS must be auto, true or false.")
|
||||
return value in {"true", "1", "yes"}
|
||||
|
||||
|
||||
def _data_paths(environment: dict, directory: Path) -> tuple[Path, Path]:
|
||||
directory = directory.absolute()
|
||||
if not directory.is_dir() or any(part.is_symlink() for part in (directory, *directory.parents)):
|
||||
raise BootstrapError("Managed installation requires a real, writable /app/data volume; symlinks are not allowed.")
|
||||
if os.name == "posix":
|
||||
metadata = directory.stat()
|
||||
if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||
raise BootstrapError("Managed data volume must belong to the runtime user and not be writable by other users.")
|
||||
database = directory / "magent.db"
|
||||
configured = Path(environment.get("SQLITE_PATH") or str(database)).absolute()
|
||||
if configured != database:
|
||||
raise BootstrapError("Managed installation requires SQLITE_PATH=/app/data/magent.db; retain manual keys for custom paths.")
|
||||
if os.path.lexists(database) and (database.is_symlink() or not database.is_file()):
|
||||
raise BootstrapError("Managed database must be a regular file, not a symlink or directory.")
|
||||
return directory / STATE_FILENAME, database
|
||||
|
||||
|
||||
def _read_state(path: Path) -> dict:
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
|
||||
with os.fdopen(descriptor, "rb") as handle:
|
||||
metadata = os.fstat(handle.fileno())
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_STATE_BYTES:
|
||||
raise BootstrapError("Managed secrets file must be a small regular file.")
|
||||
if os.name == "posix" and (
|
||||
metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||
):
|
||||
raise BootstrapError("Managed secrets file must belong to the runtime user with permissions 0600.")
|
||||
state = json.loads(handle.read(MAX_STATE_BYTES + 1))
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except (OSError, ValueError, UnicodeError) as exc:
|
||||
if isinstance(exc, BootstrapError):
|
||||
raise
|
||||
raise BootstrapError("Cannot read managed secrets. Restore the original file; keys will not be regenerated.") from None
|
||||
if not isinstance(state, dict) or set(state) != {"version", *SECRET_NAMES} or type(state["version"]) is not int or state["version"] != 1:
|
||||
raise BootstrapError("Invalid managed secrets format. Restore the original file; keys will not be regenerated.")
|
||||
for key in SECRET_NAMES:
|
||||
if not isinstance(state[key], str):
|
||||
raise BootstrapError("Invalid managed secret values. Restore the original file.")
|
||||
for key in ("JWT_SECRET", "SETUP_TOKEN"):
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{64}", state[key]) or len(set(state[key])) < 2:
|
||||
raise BootstrapError("Invalid managed token. Restore the original file.")
|
||||
try:
|
||||
decoded = base64.b64decode(state["SETTINGS_ENCRYPTION_KEY"], altchars=b"-_", validate=True)
|
||||
except (ValueError, binascii.Error):
|
||||
raise BootstrapError("Invalid managed encryption key. Restore the original file.") from None
|
||||
if len(decoded) != 32 or base64.urlsafe_b64encode(decoded).decode() != state["SETTINGS_ENCRYPTION_KEY"]:
|
||||
raise BootstrapError("Invalid managed encryption key. Restore the original file.")
|
||||
if state["JWT_SECRET"] == state["SETUP_TOKEN"]:
|
||||
raise BootstrapError("Managed signing and setup tokens must be independent.")
|
||||
return state
|
||||
|
||||
|
||||
def _sync_directory(directory: Path) -> None:
|
||||
if os.name == "posix":
|
||||
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _create_state(path: Path) -> dict:
|
||||
state = {
|
||||
"version": 1,
|
||||
"JWT_SECRET": secrets.token_urlsafe(48),
|
||||
"SETTINGS_ENCRYPTION_KEY": base64.urlsafe_b64encode(secrets.token_bytes(32)).decode(),
|
||||
"SETUP_TOKEN": secrets.token_urlsafe(48),
|
||||
}
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=".magent-secrets-", dir=path.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle, separators=(",", ":"))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
try:
|
||||
# Publish an entirely written file without replacing another
|
||||
# initializer's state. Both callers subsequently read the winner.
|
||||
os.link(temporary, path)
|
||||
_sync_directory(path.parent)
|
||||
except FileExistsError:
|
||||
pass
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return _read_state(path)
|
||||
|
||||
|
||||
def _saved_origin(database: Path) -> str:
|
||||
if not database.exists():
|
||||
return ""
|
||||
try:
|
||||
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)) as connection:
|
||||
if not connection.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'").fetchone():
|
||||
return ""
|
||||
row = connection.execute("SELECT value FROM settings WHERE key='magent_application_url'").fetchone()
|
||||
return str(row[0] or "") if row else ""
|
||||
except sqlite3.Error:
|
||||
raise BootstrapError("Cannot read the saved application address. Check the existing database; no keys were changed.") from None
|
||||
|
||||
|
||||
def _configure_origin(environment: dict, database: Path) -> None:
|
||||
value = environment.get("MAGENT_APPLICATION_URL", "")
|
||||
saved = _saved_origin(database)
|
||||
if saved:
|
||||
value = saved
|
||||
if not value:
|
||||
# No network address is trusted automatically. The token-authorized
|
||||
# first-admin transaction will save the explicitly confirmed origin.
|
||||
environment.setdefault("CORS_ALLOW_ORIGIN", "http://localhost:3000")
|
||||
environment.setdefault("AUTH_COOKIE_SECURE", "false")
|
||||
return
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
valid = (
|
||||
bool(value) and not any(c.isspace() or ord(c) < 33 or ord(c) == 127 for c in value)
|
||||
and parsed.scheme in {"http", "https"} and parsed.hostname
|
||||
and parsed.username is None and parsed.password is None and not parsed.path
|
||||
and "?" not in value and "#" not in value and "\\" not in value and "*" not in value
|
||||
and (parsed.port is None or 1 <= parsed.port <= 65535)
|
||||
)
|
||||
except ValueError:
|
||||
valid = False
|
||||
if not valid:
|
||||
raise BootstrapError("Set MAGENT_APPLICATION_URL to the exact http(s) browser origin, with no path or trailing slash.")
|
||||
if not saved and environment.get("CORS_ALLOW_ORIGIN") not in (None, "", value):
|
||||
raise BootstrapError("CORS_ALLOW_ORIGIN must match MAGENT_APPLICATION_URL for a managed install.")
|
||||
value = normalize_application_origin(value)
|
||||
environment["MAGENT_APPLICATION_URL"] = value
|
||||
environment["CORS_ALLOW_ORIGIN"] = value
|
||||
secure = environment.get("AUTH_COOKIE_SECURE", "").strip().lower()
|
||||
if not secure:
|
||||
environment["AUTH_COOKIE_SECURE"] = str(parsed.scheme == "https").lower()
|
||||
elif secure not in {"true", "false", "1", "0"}:
|
||||
raise BootstrapError("AUTH_COOKIE_SECURE must be true or false.")
|
||||
elif parsed.scheme == "https" and secure in {"false", "0"}:
|
||||
raise BootstrapError("HTTPS managed installations require AUTH_COOKIE_SECURE=true.")
|
||||
elif parsed.scheme == "http" and secure in {"true", "1"}:
|
||||
raise BootstrapError("Secure cookies require an HTTPS application URL.")
|
||||
|
||||
|
||||
def prepare_environment(environment: dict, directory: Path = DATA_DIRECTORY) -> dict:
|
||||
prepared = dict(environment)
|
||||
if not managed_mode(prepared):
|
||||
return prepared
|
||||
if not prepared.get("JWT_SECRET", "").strip():
|
||||
prepared.pop("JWT_SECRET", None)
|
||||
path, database = _data_paths(prepared, directory)
|
||||
_configure_origin(prepared, database)
|
||||
if prepared.get("API_DOCS_ENABLED", "false").strip().lower() not in {"", "false", "0"}:
|
||||
raise BootstrapError("API_DOCS_ENABLED is fixed to false for managed installations.")
|
||||
try:
|
||||
state = _read_state(path)
|
||||
except FileNotFoundError:
|
||||
# Never add independent encryption to an existing JWT-derived database
|
||||
# or invent replacement keys after a lost secrets file.
|
||||
if any(os.path.lexists(str(database) + suffix) for suffix in ("", "-wal", "-shm", "-journal")):
|
||||
raise BootstrapError("Existing database has no managed secrets file. Restore its original keys or use the existing manual deployment.") from None
|
||||
if any(prepared.get(key) for key in SECRET_NAMES):
|
||||
raise BootstrapError("Fresh managed installs generate their own keys. Remove manual key variables or disable managed mode.") from None
|
||||
state = _create_state(path)
|
||||
for key in SECRET_NAMES:
|
||||
if prepared.get(key) and prepared[key] != state[key]:
|
||||
raise BootstrapError(f"{key} conflicts with the persistent managed value. Keys will not be replaced.")
|
||||
prepared[key] = state[key]
|
||||
prepared["SQLITE_PATH"] = str(database)
|
||||
prepared["API_DOCS_ENABLED"] = "false"
|
||||
prepared["MAGENT_MANAGED_SECRETS"] = "true"
|
||||
prepared["MAGENT_RUNTIME_MANAGED"] = "1"
|
||||
return prepared
|
||||
|
||||
|
||||
def setup_token(environment: dict, directory: Path = DATA_DIRECTORY) -> str:
|
||||
if not managed_mode(environment):
|
||||
raise BootstrapError("Managed secrets are disabled. Use the SETUP_TOKEN from your deployment configuration.")
|
||||
path, database = _data_paths(environment, directory)
|
||||
state = _read_state(path) # This read-only command never generates keys.
|
||||
if database.is_symlink() or not database.is_file():
|
||||
raise BootstrapError("Database is not initialized. Wait for the container to become healthy.")
|
||||
try:
|
||||
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)) as connection:
|
||||
row = connection.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
|
||||
admin = connection.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
|
||||
except sqlite3.Error:
|
||||
raise BootstrapError("Cannot verify setup state. No setup token will be displayed.") from None
|
||||
if row is None or row[0] != 0 or admin is not None:
|
||||
raise BootstrapError("Initial administrator setup is no longer available. Sign in with the existing administrator.")
|
||||
return state["SETUP_TOKEN"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
if sys.argv[1:] == ["setup-token"]:
|
||||
print(setup_token(dict(os.environ)))
|
||||
return 0
|
||||
if len(sys.argv) < 2:
|
||||
raise BootstrapError("Pass the container startup command, or setup-token from the operator console.")
|
||||
environment = prepare_environment(dict(os.environ))
|
||||
if managed_mode(environment):
|
||||
print("Managed installation secrets loaded. For first setup, run in the container console: "
|
||||
"python -m app.container_bootstrap setup-token", flush=True)
|
||||
os.execvpe(sys.argv[1], sys.argv[1:], environment)
|
||||
except (BootstrapError, OSError):
|
||||
# Never include unexpected I/O details or environment values in logs.
|
||||
error = sys.exc_info()[1]
|
||||
message = str(error) if isinstance(error, BootstrapError) else "Cannot access managed installation files or start the runtime. Check volume permissions and original keys."
|
||||
print(f"Magent startup: {message}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+3018
-214
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
"""Live account permissions. Invite access uses the existing users column."""
|
||||
from .db import _connect
|
||||
|
||||
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
|
||||
JOIN users u ON u.id = p.user_id WHERE u.username = ? COLLATE NOCASE""",
|
||||
(user.get("username", ""),)).fetchall()
|
||||
values.update({key: bool(enabled) for key, enabled in rows if key in FEATURES and key != "invites"})
|
||||
return values
|
||||
|
||||
|
||||
def update_permissions(changes: dict[str, bool], username: str | None = None) -> int:
|
||||
if not changes or any(key not in FEATURES or type(value) is not bool for key, value in changes.items()):
|
||||
raise ValueError("Choose valid features with true or false values")
|
||||
with _connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
users = conn.execute("SELECT id FROM users WHERE role != 'admin'" +
|
||||
(" AND username = ? COLLATE NOCASE" if username is not None else ""),
|
||||
(username,) if username is not None else ()).fetchall()
|
||||
for (user_id,) in users:
|
||||
for feature, enabled in changes.items():
|
||||
if feature == "invites":
|
||||
conn.execute("UPDATE users SET invite_management_enabled = ? WHERE id = ?", (int(enabled), user_id))
|
||||
else:
|
||||
conn.execute("""INSERT INTO user_feature_permissions(user_id, feature, enabled) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, feature) DO UPDATE SET enabled = excluded.enabled""",
|
||||
(user_id, feature, int(enabled)))
|
||||
return len(users)
|
||||
@@ -0,0 +1,75 @@
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from .auth import get_current_user, get_current_user_event_stream
|
||||
from .db import get_portal_item
|
||||
|
||||
|
||||
def check(user: dict, *features: str) -> None:
|
||||
access = user.get("features") or {}
|
||||
if user.get("role") == "admin":
|
||||
return
|
||||
if not any(access.get(feature, False) for feature in features):
|
||||
raise HTTPException(status_code=403, detail="This feature is disabled for your account")
|
||||
|
||||
|
||||
def require_stats(user: dict = Depends(get_current_user)) -> dict:
|
||||
check(user, "stats")
|
||||
return user
|
||||
|
||||
|
||||
def require_invites(user: dict = Depends(get_current_user)) -> dict:
|
||||
check(user, "invites")
|
||||
return user
|
||||
|
||||
|
||||
def require_request_access(request: Request, user: dict = Depends(get_current_user)) -> None:
|
||||
path = request.url.path.rstrip("/")
|
||||
if path.endswith("/search") and "/actions/" not in path:
|
||||
# The issue picker uses the same media search; creation is checked separately.
|
||||
check(user, "new_requests", "issues")
|
||||
elif path.endswith(("/create", "/request-options")):
|
||||
check(user, "new_requests")
|
||||
elif path.endswith(("/issue-options", "/replacement-options", "/actions/replace", "/actions/search-missing", "/actions/repair-subtitles")):
|
||||
check(user, "issues")
|
||||
else:
|
||||
check(user, "requests")
|
||||
|
||||
|
||||
async def require_portal_access(request: Request, user: dict = Depends(get_current_user)) -> None:
|
||||
if user.get("role") == "admin":
|
||||
return
|
||||
path = request.url.path.rstrip("/")
|
||||
access = user.get("features", {})
|
||||
if access.get("requests") and access.get("issues") and access.get("new_requests"):
|
||||
return
|
||||
if "/issues" in path:
|
||||
check(user, "issues")
|
||||
elif path.endswith("/requests") or path.endswith("/pipeline"):
|
||||
check(user, "requests")
|
||||
elif "item_id" in request.path_params:
|
||||
try:
|
||||
item = get_portal_item(int(request.path_params["item_id"]))
|
||||
except (ValueError, TypeError):
|
||||
item = None
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
check(user, "requests" if item.get("kind") == "request" else "issues")
|
||||
elif path.endswith("/items") and request.method == "POST":
|
||||
payload = await request.json()
|
||||
kind = str(payload.get("kind") or "").strip().lower() if isinstance(payload, dict) else ""
|
||||
check(user, "new_requests" if not kind or kind == "request" else "issues")
|
||||
elif path.endswith(("/items", "/overview")) and request.query_params.get("kind"):
|
||||
kind = request.query_params["kind"].strip().lower()
|
||||
if not kind:
|
||||
check(user, "requests")
|
||||
check(user, "issues")
|
||||
else:
|
||||
check(user, "requests" if kind == "request" else "issues")
|
||||
else:
|
||||
# Unfiltered lists/overview can include both kinds.
|
||||
check(user, "requests")
|
||||
check(user, "issues")
|
||||
|
||||
|
||||
def require_request_stream(user: dict = Depends(get_current_user_event_stream)) -> dict:
|
||||
check(user, "requests")
|
||||
return user
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Origin validation shared by first-install setup and container startup."""
|
||||
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def managed_runtime() -> bool:
|
||||
# Set by the entrypoint, never by an HTTP header or a database setting.
|
||||
return os.environ.get("MAGENT_RUNTIME_MANAGED") == "1"
|
||||
|
||||
|
||||
def normalize_application_origin(value: str) -> str:
|
||||
if not isinstance(value, str) or not value or any(
|
||||
c.isspace() or ord(c) < 33 or ord(c) == 127 or c in '<>"\\*?#' for c in value
|
||||
):
|
||||
raise ValueError("Enter an exact http(s) site address without a path, credentials, query or fragment.")
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.hostname
|
||||
or parsed.username is not None or parsed.password is not None
|
||||
or parsed.path not in {"", "/"} or parsed.netloc.endswith(":")):
|
||||
raise ValueError
|
||||
port = parsed.port
|
||||
if port is not None and not 1 <= port <= 65535:
|
||||
raise ValueError
|
||||
host = parsed.hostname.encode("idna").decode("ascii").lower()
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
suffix = f":{port}" if port is not None and port != (443 if parsed.scheme == "https" else 80) else ""
|
||||
return f"{parsed.scheme}://{host}{suffix}"
|
||||
except (ValueError, UnicodeError):
|
||||
raise ValueError("Enter an exact http(s) site address without a path, credentials, query or fragment.") from None
|
||||
@@ -1,10 +1,174 @@
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
from typing import Any, Mapping, Optional
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
REQUEST_ID_CONTEXT: contextvars.ContextVar[str] = contextvars.ContextVar(
|
||||
"magent_request_id", default="-"
|
||||
)
|
||||
|
||||
_SENSITIVE_KEYWORDS = (
|
||||
"api_key",
|
||||
"authorization",
|
||||
"cert",
|
||||
"cookie",
|
||||
"jwt",
|
||||
"key",
|
||||
"pass",
|
||||
"password",
|
||||
"pem",
|
||||
"private",
|
||||
"secret",
|
||||
"session",
|
||||
"signature",
|
||||
"token",
|
||||
)
|
||||
_MAX_BODY_BYTES = 4096
|
||||
_SENSITIVE_PATH_PATTERNS = (
|
||||
re.compile(r"(/auth/invites/)[^/]+", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None:
|
||||
class RequestContextFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.request_id = REQUEST_ID_CONTEXT.get("-")
|
||||
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 "-")
|
||||
|
||||
|
||||
def reset_request_id(token: contextvars.Token[str]) -> None:
|
||||
REQUEST_ID_CONTEXT.reset(token)
|
||||
|
||||
|
||||
def current_request_id() -> str:
|
||||
return REQUEST_ID_CONTEXT.get("-")
|
||||
|
||||
|
||||
def 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)
|
||||
|
||||
|
||||
def _redact_scalar(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
return "[REDACTED]"
|
||||
|
||||
|
||||
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
|
||||
if key_hint and _is_sensitive_key(key_hint):
|
||||
return _redact_scalar(value)
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
return f"<bytes:{len(value)}>"
|
||||
if isinstance(value, str):
|
||||
return value if len(value) <= 512 else f"{value[:509]}..."
|
||||
if depth >= 3:
|
||||
return f"<{type(value).__name__}>"
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): sanitize_value(item, key_hint=str(key), depth=depth + 1)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [sanitize_value(item, depth=depth + 1) for item in list(value)[:20]]
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return sanitize_value(value.model_dump(), depth=depth + 1)
|
||||
except Exception:
|
||||
return f"<{type(value).__name__}>"
|
||||
return str(value)
|
||||
|
||||
|
||||
def sanitize_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
str(key).lower(): sanitize_value(value, key_hint=str(key))
|
||||
for key, value in headers.items()
|
||||
}
|
||||
|
||||
|
||||
def summarize_http_body(body: bytes, content_type: Optional[str]) -> Any:
|
||||
if not body:
|
||||
return None
|
||||
normalized = (content_type or "").split(";")[0].strip().lower()
|
||||
if normalized == "application/json":
|
||||
preview = body[:_MAX_BODY_BYTES]
|
||||
try:
|
||||
payload = json.loads(preview.decode("utf-8"))
|
||||
summary = sanitize_value(payload)
|
||||
if len(body) > _MAX_BODY_BYTES:
|
||||
return {"truncated": True, "bytes": len(body), "payload": summary}
|
||||
return summary
|
||||
except Exception:
|
||||
pass
|
||||
if normalized == "application/x-www-form-urlencoded":
|
||||
try:
|
||||
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
||||
compact = {
|
||||
key: value[0] if len(value) == 1 else value
|
||||
for key, value in parsed.items()
|
||||
}
|
||||
return sanitize_value(compact)
|
||||
except Exception:
|
||||
pass
|
||||
if normalized.startswith("multipart/"):
|
||||
return {"content_type": normalized, "bytes": len(body)}
|
||||
preview = body[: min(len(body), 256)].decode("utf-8", errors="replace")
|
||||
return {
|
||||
"content_type": normalized or "unknown",
|
||||
"bytes": len(body),
|
||||
"preview": preview if len(body) <= 256 else f"{preview}...",
|
||||
}
|
||||
|
||||
|
||||
def _coerce_level(level_name: Optional[str], fallback: int) -> int:
|
||||
if not level_name:
|
||||
return fallback
|
||||
return getattr(logging, str(level_name).upper(), fallback)
|
||||
|
||||
|
||||
def configure_logging(
|
||||
log_level: Optional[str],
|
||||
log_file: Optional[str],
|
||||
*,
|
||||
log_file_max_bytes: int = 20_000_000,
|
||||
log_file_backup_count: int = 10,
|
||||
log_http_client_level: Optional[str] = "INFO",
|
||||
log_background_sync_level: Optional[str] = "INFO",
|
||||
log_format: Optional[str] = "text",
|
||||
) -> None:
|
||||
level_name = (log_level or "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
|
||||
@@ -18,15 +182,27 @@ def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None
|
||||
log_path = os.path.join(os.getcwd(), log_path)
|
||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_path, maxBytes=2_000_000, backupCount=3, encoding="utf-8"
|
||||
log_path,
|
||||
maxBytes=max(1_000_000, int(log_file_max_bytes or 20_000_000)),
|
||||
backupCount=max(1, int(log_file_backup_count or 10)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
os.chmod(log_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
handlers.append(file_handler)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
context_filter = RequestContextFilter()
|
||||
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)
|
||||
|
||||
root = logging.getLogger()
|
||||
@@ -38,4 +214,10 @@ def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None
|
||||
|
||||
logging.getLogger("uvicorn").setLevel(level)
|
||||
logging.getLogger("uvicorn.error").setLevel(level)
|
||||
logging.getLogger("uvicorn.access").setLevel(level)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
http_client_level = _coerce_level(log_http_client_level, logging.DEBUG)
|
||||
background_sync_level = _coerce_level(log_background_sync_level, logging.INFO)
|
||||
logging.getLogger("app.clients.base").setLevel(http_client_level)
|
||||
logging.getLogger("app.routers.requests").setLevel(background_sync_level)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING if level > logging.DEBUG else logging.INFO)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
+331
-13
@@ -1,60 +1,378 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.exception_handlers import request_validation_exception_handler
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .config import settings
|
||||
from .db import init_db
|
||||
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,
|
||||
)
|
||||
from .routers.auth import router as auth_router
|
||||
from .routers.admin import router as admin_router
|
||||
from .routers.admin import router as admin_router, events_router as admin_events_router
|
||||
from .routers.images import router as images_router
|
||||
from .routers.branding import router as branding_router
|
||||
from .routers.status import router as status_router
|
||||
from .routers.feedback import router as feedback_router
|
||||
from .routers.site import router as site_router
|
||||
from .routers.events import router as events_router
|
||||
from .routers.portal import router as portal_router
|
||||
from .routers.operations import router as operations_router
|
||||
from .routers.insights import router as insights_router
|
||||
from .routers.identities import router as identities_router
|
||||
from .routers.recaps import router as recaps_router
|
||||
from .routers.newsletters import router as newsletters_router
|
||||
from .routers.backups import router as backups_router
|
||||
from .routers.setup import router as setup_router
|
||||
from .services.backups import apply_pending_restore
|
||||
from .services.setup import initialize_setup_state, is_setup_required, setup_token_configured
|
||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||
from .logging_config import configure_logging
|
||||
from .services.issue_resolution import run_issue_confirmation_loop
|
||||
from .services.email_recaps import run_email_recap_loop
|
||||
from .services.newsletters import run_newsletter_loop
|
||||
from .services.operation_progress import (
|
||||
begin_operation,
|
||||
finish_operation,
|
||||
normalize_operation_id,
|
||||
reset_operation,
|
||||
)
|
||||
from .logging_config import (
|
||||
bind_request_id,
|
||||
configure_logging,
|
||||
reset_request_id,
|
||||
sanitize_headers,
|
||||
sanitize_path,
|
||||
)
|
||||
from .runtime import get_runtime_settings
|
||||
from .metrics import record_api, start_metrics
|
||||
from .request_limits import InstallationBodyLimitMiddleware
|
||||
from .secret_storage import validate_secret_storage_configuration
|
||||
from .services.request_origins import ConfiguredOriginCORSMiddleware, can_claim_initial_origin, is_allowed_request_origin
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
logger = logging.getLogger(__name__)
|
||||
_background_tasks: list[asyncio.Task[None]] = []
|
||||
_background_started = False
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
docs_url="/docs" if settings.api_docs_enabled else None,
|
||||
redoc_url=None,
|
||||
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
ConfiguredOriginCORSMiddleware,
|
||||
allow_origins=[settings.cors_allow_origin],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def installation_validation_error(request: Request, exc: RequestValidationError):
|
||||
if request.url.path.rstrip("/") == "/setup/bootstrap" or request.url.path.startswith("/admin/backups"):
|
||||
# Pydantic SecretStr masks parsed values, but FastAPI's default 422 body
|
||||
# includes rejected raw input. Never echo tokens/passwords/passphrases.
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": [
|
||||
{key: error[key] for key in ("type", "loc", "msg") if key in error}
|
||||
for error in exc.errors()
|
||||
]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return await request_validation_exception_handler(request, exc)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||
token = bind_request_id(request_id)
|
||||
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
|
||||
operation_token = None
|
||||
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
||||
operation_token = begin_operation(
|
||||
operation_id,
|
||||
label=request.headers.get("X-Magent-Operation-Label"),
|
||||
path=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 "")
|
||||
initial_origin_claim = (
|
||||
request.method.upper() == "POST" and request.url.path == "/setup/bootstrap"
|
||||
and can_claim_initial_origin()
|
||||
)
|
||||
if origin and not is_allowed_request_origin(origin) and not initial_origin_claim:
|
||||
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_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_keys=%s client=%s headers=%s body=%s",
|
||||
request.method,
|
||||
sanitize_path(request.url.path),
|
||||
sorted(set(request.query_params.keys())),
|
||||
request.client.host if request.client else "-",
|
||||
sanitize_headers(
|
||||
{
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower()
|
||||
in {
|
||||
"content-type",
|
||||
"content-length",
|
||||
"user-agent",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-proto",
|
||||
"x-request-id",
|
||||
}
|
||||
}
|
||||
),
|
||||
body_summary,
|
||||
)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
record_api(request, 500, time.perf_counter() - started_at)
|
||||
logger.exception(
|
||||
"request failed method=%s path=%s duration_ms=%s",
|
||||
request.method,
|
||||
sanitize_path(request.url.path),
|
||||
duration_ms,
|
||||
)
|
||||
if operation_id and operation_token is not None:
|
||||
finish_operation(operation_id, success=False, status_code=500)
|
||||
reset_operation(operation_token)
|
||||
reset_request_id(token)
|
||||
raise
|
||||
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
record_api(request, response.status_code, time.perf_counter() - started_at)
|
||||
response.headers.setdefault("X-Request-ID", request_id)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||
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(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
|
||||
)
|
||||
logger.info(
|
||||
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
|
||||
request.method,
|
||||
sanitize_path(request.url.path),
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
sanitize_headers(
|
||||
{
|
||||
key: value
|
||||
for key, value in response.headers.items()
|
||||
if key.lower() in {"content-type", "content-length", "x-request-id"}
|
||||
}
|
||||
),
|
||||
)
|
||||
if operation_id and operation_token is not None:
|
||||
finish_operation(
|
||||
operation_id,
|
||||
success=response.status_code < 400,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
reset_operation(operation_token)
|
||||
reset_request_id(token)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _run_background_task(
|
||||
name: str, coroutine_factory: Callable[[], Awaitable[None]]
|
||||
) -> None:
|
||||
token = bind_request_id(f"task-{name}")
|
||||
logger.info("background task started task=%s", name)
|
||||
try:
|
||||
await coroutine_factory()
|
||||
logger.warning("background task exited task=%s", name)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("background task cancelled task=%s", name)
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("background task crashed task=%s", name)
|
||||
raise
|
||||
finally:
|
||||
reset_request_id(token)
|
||||
|
||||
|
||||
def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable[None]]) -> None:
|
||||
task = asyncio.create_task(
|
||||
_run_background_task(name, coroutine_factory), name=f"magent:{name}"
|
||||
)
|
||||
_background_tasks.append(task)
|
||||
|
||||
|
||||
def _log_security_configuration_warnings() -> None:
|
||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||
if len(jwt_secret) < 32 or jwt_secret == "change-me":
|
||||
logger.warning(
|
||||
"security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
|
||||
)
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if admin_password == "adminadmin":
|
||||
logger.warning(
|
||||
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
|
||||
)
|
||||
if bool(settings.api_docs_enabled):
|
||||
logger.warning(
|
||||
"security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
|
||||
)
|
||||
|
||||
|
||||
def _enforce_secret_configuration() -> None:
|
||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||
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"):
|
||||
if is_setup_required() and setup_token_configured():
|
||||
return
|
||||
raise RuntimeError(
|
||||
"First startup requires a strong SETUP_TOKEN (at least 32 characters) for the setup wizard, "
|
||||
"or a secure ADMIN_PASSWORD, until an admin account exists."
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
start_metrics()
|
||||
configure_logging(
|
||||
settings.log_level,
|
||||
settings.log_file,
|
||||
log_file_max_bytes=settings.log_file_max_bytes,
|
||||
log_file_backup_count=settings.log_file_backup_count,
|
||||
log_http_client_level=settings.log_http_client_level,
|
||||
log_background_sync_level=settings.log_background_sync_level,
|
||||
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()
|
||||
# Restore offline, before any schema migration, database reader or worker.
|
||||
apply_pending_restore()
|
||||
initialize_setup_state()
|
||||
init_db()
|
||||
_enforce_secure_startup_configuration()
|
||||
runtime = get_runtime_settings()
|
||||
configure_logging(runtime.log_level, runtime.log_file)
|
||||
asyncio.create_task(run_daily_jellyfin_sync())
|
||||
asyncio.create_task(startup_warmup_requests_cache())
|
||||
asyncio.create_task(run_requests_delta_loop())
|
||||
asyncio.create_task(run_daily_requests_full_sync())
|
||||
asyncio.create_task(run_daily_db_cleanup())
|
||||
configure_logging(
|
||||
runtime.log_level,
|
||||
runtime.log_file,
|
||||
log_file_max_bytes=runtime.log_file_max_bytes,
|
||||
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",
|
||||
runtime.log_level,
|
||||
runtime.log_file,
|
||||
runtime.log_file_max_bytes,
|
||||
runtime.log_file_backup_count,
|
||||
runtime.log_http_client_level,
|
||||
runtime.log_background_sync_level,
|
||||
runtime.requests_data_source,
|
||||
)
|
||||
app.state.on_setup_complete = _start_background_tasks
|
||||
await _start_background_tasks()
|
||||
logger.info("startup complete")
|
||||
|
||||
|
||||
async def _start_background_tasks() -> None:
|
||||
global _background_started
|
||||
if _background_started:
|
||||
return
|
||||
if is_setup_required():
|
||||
logger.info("Background imports and automation paused until setup is complete")
|
||||
return
|
||||
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
|
||||
logger.info("Background imports and automation disabled by configuration")
|
||||
return
|
||||
_background_started = True
|
||||
_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)
|
||||
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
||||
_launch_background_task("email-recaps", run_email_recap_loop)
|
||||
_launch_background_task("newsletters", run_newsletter_loop)
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown() -> None:
|
||||
global _background_started
|
||||
for task in _background_tasks:
|
||||
task.cancel()
|
||||
if _background_tasks:
|
||||
await asyncio.gather(*_background_tasks, return_exceptions=True)
|
||||
_background_tasks.clear()
|
||||
_background_started = False
|
||||
|
||||
|
||||
app.include_router(requests_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(admin_events_router)
|
||||
app.include_router(images_router)
|
||||
app.include_router(branding_router)
|
||||
app.include_router(status_router)
|
||||
app.include_router(feedback_router)
|
||||
app.include_router(site_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(portal_router)
|
||||
app.include_router(operations_router)
|
||||
app.include_router(insights_router)
|
||||
app.include_router(identities_router)
|
||||
app.include_router(recaps_router)
|
||||
app.include_router(newsletters_router)
|
||||
app.include_router(backups_router)
|
||||
app.include_router(setup_router)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Low-cardinality operational metrics; no URLs, query values or user data."""
|
||||
import os
|
||||
from prometheus_client import Counter, Histogram, start_http_server
|
||||
|
||||
BUCKETS = (.01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60)
|
||||
API_CALLS = Counter('magent_api_requests_total', 'API responses by route template', ['method', 'route', 'status'])
|
||||
API_TIME = Histogram('magent_api_response_seconds', 'Time until response headers (not stream lifetime)', ['method', 'route'], buckets=BUCKETS)
|
||||
REMOTE_CALLS = Counter('magent_remote_requests_total', 'Logical service client calls', ['service', 'method', 'status'])
|
||||
REMOTE_TIME = Histogram('magent_remote_response_seconds', 'Logical service client call duration', ['service', 'method'], buckets=BUCKETS)
|
||||
_server = None
|
||||
|
||||
def start_metrics():
|
||||
global _server
|
||||
if _server is None and os.getenv('MAGENT_METRICS_ENABLED', '').lower() == 'true':
|
||||
_server = start_http_server(int(os.getenv('MAGENT_METRICS_PORT', '9108')), addr=os.getenv('MAGENT_METRICS_BIND', '127.0.0.1'))
|
||||
|
||||
def record_api(request, status, seconds):
|
||||
route = getattr(request.scope.get('route'), 'path', 'unmatched')
|
||||
method = request.method if request.method in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} else 'OTHER'
|
||||
API_CALLS.labels(method, route, str(status)).inc()
|
||||
API_TIME.labels(method, route).observe(max(0, seconds))
|
||||
|
||||
def record_remote(service, method, status, seconds):
|
||||
service = service if service in {'Seerr', 'Jellyfin', 'Sonarr', 'Radarr', 'Bazarr', 'Prowlarr', 'qBittorrent'} else 'Other'
|
||||
method = method.upper() if method.upper() in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} else 'OTHER'
|
||||
REMOTE_CALLS.labels(service, method, str(status)).inc()
|
||||
REMOTE_TIME.labels(service, method).observe(max(0, seconds))
|
||||
@@ -35,6 +35,7 @@ class ActionOption(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
risk: str
|
||||
description: Optional[str] = None
|
||||
requires_confirmation: bool = True
|
||||
|
||||
|
||||
@@ -48,6 +49,7 @@ class Snapshot(BaseModel):
|
||||
timeline: List[TimelineHop] = Field(default_factory=list)
|
||||
actions: List[ActionOption] = Field(default_factory=list)
|
||||
artwork: Dict[str, Any] = Field(default_factory=dict)
|
||||
presentation: Dict[str, Any] = Field(default_factory=dict)
|
||||
raw: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from functools import lru_cache
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .config import settings
|
||||
|
||||
_METADATA_HOSTS = {
|
||||
"169.254.169.254",
|
||||
"metadata.google.internal",
|
||||
"metadata.azure.internal",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _split_csv(value: object) -> list[str]:
|
||||
raw = _normalize_text(value)
|
||||
if not raw:
|
||||
return []
|
||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
||||
|
||||
|
||||
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
|
||||
return bool(
|
||||
ip_obj.is_loopback
|
||||
or ip_obj.is_link_local
|
||||
or ip_obj.is_multicast
|
||||
or ip_obj.is_unspecified
|
||||
or ip_obj.is_reserved
|
||||
or ip_obj.is_private
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
|
||||
resolved: list[ipaddress._BaseAddress] = []
|
||||
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
||||
if family == socket.AF_INET:
|
||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
||||
elif family == socket.AF_INET6:
|
||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
||||
return tuple(resolved)
|
||||
|
||||
|
||||
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
|
||||
candidate = _normalize_text(host)
|
||||
if not candidate:
|
||||
return False
|
||||
try:
|
||||
host_ip = ipaddress.ip_address(candidate)
|
||||
except ValueError:
|
||||
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
|
||||
|
||||
for entry in trusted_proxies:
|
||||
raw = _normalize_text(entry)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
if "/" in raw:
|
||||
if host_ip in ipaddress.ip_network(raw, strict=False):
|
||||
return True
|
||||
elif host_ip == ipaddress.ip_address(raw):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
|
||||
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
|
||||
return False
|
||||
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
|
||||
if not trusted:
|
||||
return False
|
||||
return _is_trusted_proxy_host(client_host or "", trusted)
|
||||
|
||||
|
||||
def validate_notification_target_url(
|
||||
url: str,
|
||||
*,
|
||||
allow_private: bool | None = None,
|
||||
) -> str:
|
||||
raw = _normalize_text(url)
|
||||
if not raw:
|
||||
raise ValueError("URL cannot be empty.")
|
||||
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("URL must use http:// or https://.")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("URL must not embed credentials.")
|
||||
hostname = _normalize_text(parsed.hostname).lower()
|
||||
if not hostname:
|
||||
raise ValueError("URL must include a valid host.")
|
||||
|
||||
allow_private_targets = (
|
||||
settings.magent_allow_private_notification_targets
|
||||
if allow_private is None
|
||||
else bool(allow_private)
|
||||
)
|
||||
if hostname in _METADATA_HOSTS:
|
||||
raise ValueError("Metadata service targets are not allowed.")
|
||||
if hostname == "localhost" and not allow_private_targets:
|
||||
raise ValueError("Local notification targets are not allowed.")
|
||||
|
||||
try:
|
||||
host_ip = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
host_ip = None
|
||||
|
||||
if host_ip is not None:
|
||||
if _ip_is_sensitive(host_ip) and not allow_private_targets:
|
||||
raise ValueError("Private or local notification targets are not allowed.")
|
||||
return raw
|
||||
|
||||
try:
|
||||
resolved_ips = _resolve_host_ips(hostname)
|
||||
except socket.gaierror as exc:
|
||||
raise ValueError("Host could not be resolved.") from exc
|
||||
if not resolved_ips:
|
||||
raise ValueError("Host could not be resolved.")
|
||||
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
|
||||
raise ValueError("Private or local notification targets are not allowed.")
|
||||
return raw
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Bound security-sensitive request bodies before JSON/multipart parsing."""
|
||||
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
|
||||
# Encrypted backup limit is 32 MiB. Allow a bounded margin for the multipart
|
||||
# envelope; count streamed chunks as well as checking the untrusted header.
|
||||
RESTORE_BODY_LIMIT = 34 * 1024 * 1024
|
||||
BOOTSTRAP_BODY_LIMIT = 16 * 1024
|
||||
|
||||
|
||||
class InstallationBodyLimitMiddleware:
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http" or scope.get("method") != "POST":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "").rstrip("/")
|
||||
limit = {
|
||||
"/admin/backups/restore": RESTORE_BODY_LIMIT,
|
||||
"/admin/backups/export": BOOTSTRAP_BODY_LIMIT,
|
||||
"/setup/bootstrap": BOOTSTRAP_BODY_LIMIT,
|
||||
}.get(path)
|
||||
if limit is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
headers = dict(scope.get("headers", []))
|
||||
try:
|
||||
length = int(headers.get(b"content-length", b"0"))
|
||||
except ValueError:
|
||||
length = -1
|
||||
if length < 0 or length > limit:
|
||||
await JSONResponse({"detail": "Request body is too large or has an invalid length."}, status_code=413)(scope, receive, send)
|
||||
return
|
||||
received = 0
|
||||
|
||||
async def bounded_receive() -> Message:
|
||||
nonlocal received
|
||||
message = await receive()
|
||||
if message["type"] == "http.request":
|
||||
received += len(message.get("body", b""))
|
||||
if received > limit:
|
||||
raise HTTPException(status_code=413, detail="Request body is too large.")
|
||||
return message
|
||||
|
||||
await self.app(scope, bounded_receive, send)
|
||||
+1546
-86
File diff suppressed because it is too large
Load Diff
+1379
-75
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
"""Administrator-only encrypted backup downloads and staged restores."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..db import get_rate_limit_status, record_rate_limit_event
|
||||
from ..services import backups
|
||||
|
||||
def _no_store(response: Response) -> None:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/backups", tags=["backups"],
|
||||
dependencies=[Depends(require_admin), Depends(_no_store)],
|
||||
)
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
passphrase: SecretStr = Field(min_length=12, max_length=1024)
|
||||
include_cache: bool = False
|
||||
|
||||
|
||||
def _rate_limit(user: dict) -> None:
|
||||
key = str(user["username"])
|
||||
exceeded, retry = get_rate_limit_status("backups", key, 300, 3)
|
||||
if exceeded:
|
||||
raise HTTPException(429, "Too many backup operations; try again shortly", headers={"Retry-After": str(retry)})
|
||||
record_rate_limit_event("backups", key)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def status() -> dict:
|
||||
return backups.backup_status()
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
def export(payload: ExportRequest, user: dict = Depends(require_admin)) -> Response:
|
||||
_rate_limit(user)
|
||||
try:
|
||||
content, filename = backups.create_backup(payload.passphrase.get_secret_value(), payload.include_cache)
|
||||
except backups.BackupError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return Response(content, media_type="application/octet-stream", headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Cache-Control": "no-store", "Pragma": "no-cache",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/restore", status_code=202)
|
||||
async def restore(
|
||||
file: UploadFile = File(...),
|
||||
passphrase: str = Form(..., min_length=12, max_length=1024),
|
||||
confirmation: Literal["RESTORE"] = Form(...),
|
||||
user: dict = Depends(require_admin),
|
||||
) -> dict:
|
||||
_rate_limit(user)
|
||||
try:
|
||||
if file.size is not None and file.size > backups.MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "Backup exceeds the 32 MiB upload limit")
|
||||
metadata = await run_in_threadpool(backups.stage_restore, file.file, passphrase)
|
||||
except backups.BackupError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
finally:
|
||||
await file.close()
|
||||
return {
|
||||
"status": "staged", "restart_required": True, "backup": metadata,
|
||||
"message": "Backup validated. Restart Magent to apply it. Current data remains active until restart.",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/restore")
|
||||
def cancel() -> dict:
|
||||
try:
|
||||
backups.cancel_restore()
|
||||
except backups.BackupError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
return {"status": "cancelled"}
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..feature_guards import require_request_stream, check
|
||||
from ..feature_access import permissions
|
||||
from ..db import get_user_by_username
|
||||
from . import requests as requests_router
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
|
||||
def _sse_json(payload: Dict[str, Any]) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return value.model_dump(mode="json")
|
||||
except TypeError:
|
||||
return value.model_dump()
|
||||
if hasattr(value, "dict"):
|
||||
try:
|
||||
return value.dict()
|
||||
except TypeError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"request_id": entry.get("request_id"),
|
||||
"state": entry.get("state"),
|
||||
"state_reason": entry.get("state_reason"),
|
||||
"created_at": entry.get("created_at"),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"request_id": entry.get("request_id"),
|
||||
"action_id": entry.get("action_id"),
|
||||
"label": entry.get("label"),
|
||||
"status": entry.get("status"),
|
||||
"message": entry.get("message"),
|
||||
"created_at": entry.get("created_at"),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def events_stream(
|
||||
request: Request,
|
||||
recent_days: int = 90,
|
||||
recent_stage: str = "all",
|
||||
user: Dict[str, Any] = Depends(require_request_stream),
|
||||
) -> StreamingResponse:
|
||||
recent_days = max(0, min(int(recent_days or 90), 3650))
|
||||
recent_take = 50 if user.get("role") == "admin" else 6
|
||||
|
||||
async def event_generator():
|
||||
yield "retry: 2000\n\n"
|
||||
last_recent_signature: Optional[str] = None
|
||||
next_recent_at = 0.0
|
||||
heartbeat_counter = 0
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
try:
|
||||
account = get_user_by_username(user.get("username", ""))
|
||||
if not account or account.get("is_blocked") or account.get("is_expired"):
|
||||
break
|
||||
check({**account, "features": permissions(account)}, "requests")
|
||||
except HTTPException:
|
||||
break
|
||||
now = time.monotonic()
|
||||
sent_any = False
|
||||
|
||||
if now >= next_recent_at:
|
||||
next_recent_at = now + 15.0
|
||||
try:
|
||||
recent_payload = await requests_router.recent_requests(
|
||||
take=recent_take,
|
||||
skip=0,
|
||||
days=recent_days,
|
||||
stage=recent_stage,
|
||||
user=user,
|
||||
)
|
||||
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
|
||||
payload = {
|
||||
"type": "home_recent",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"days": recent_days,
|
||||
"stage": recent_stage,
|
||||
"results": results if isinstance(results, list) else [],
|
||||
}
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"type": "home_recent",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"days": recent_days,
|
||||
"stage": recent_stage,
|
||||
"error": str(exc),
|
||||
}
|
||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||
if signature != last_recent_signature:
|
||||
last_recent_signature = signature
|
||||
yield _sse_json(payload)
|
||||
sent_any = True
|
||||
|
||||
if sent_any:
|
||||
heartbeat_counter = 0
|
||||
else:
|
||||
heartbeat_counter += 1
|
||||
if heartbeat_counter >= 15:
|
||||
yield ": ping\n\n"
|
||||
heartbeat_counter = 0
|
||||
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||
|
||||
|
||||
@router.get("/requests/{request_id}/stream")
|
||||
async def request_events_stream(
|
||||
request_id: str,
|
||||
request: Request,
|
||||
user: Dict[str, Any] = Depends(require_request_stream),
|
||||
) -> StreamingResponse:
|
||||
request_id = str(request_id).strip()
|
||||
if not request_id:
|
||||
raise HTTPException(status_code=400, detail="Missing request id")
|
||||
|
||||
async def event_generator():
|
||||
yield "retry: 2000\n\n"
|
||||
last_signature: Optional[str] = None
|
||||
next_refresh_at = 0.0
|
||||
heartbeat_counter = 0
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
try:
|
||||
account = get_user_by_username(user.get("username", ""))
|
||||
if not account or account.get("is_blocked") or account.get("is_expired"):
|
||||
break
|
||||
check({**account, "features": permissions(account)}, "requests")
|
||||
except HTTPException:
|
||||
break
|
||||
now = time.monotonic()
|
||||
sent_any = False
|
||||
|
||||
if now >= next_refresh_at:
|
||||
next_refresh_at = now + 2.0
|
||||
try:
|
||||
snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
|
||||
history_payload = await requests_router.request_history(
|
||||
request_id=request_id, limit=5, user=user
|
||||
)
|
||||
actions_payload = await requests_router.request_actions(
|
||||
request_id=request_id, limit=5, user=user
|
||||
)
|
||||
payload = {
|
||||
"type": "request_live",
|
||||
"request_id": request_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"snapshot": _jsonable(snapshot),
|
||||
"history": _request_history_brief(
|
||||
history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
|
||||
),
|
||||
"actions": _request_actions_brief(
|
||||
actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
|
||||
),
|
||||
}
|
||||
except HTTPException as exc:
|
||||
payload = {
|
||||
"type": "request_live",
|
||||
"request_id": request_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(exc.detail),
|
||||
"status_code": int(exc.status_code),
|
||||
}
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"type": "request_live",
|
||||
"request_id": request_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||
if signature != last_signature:
|
||||
last_signature = signature
|
||||
yield _sse_json(payload)
|
||||
sent_any = True
|
||||
|
||||
if sent_any:
|
||||
heartbeat_counter = 0
|
||||
else:
|
||||
heartbeat_counter += 1
|
||||
if heartbeat_counter >= 15:
|
||||
yield ": ping\n\n"
|
||||
heartbeat_counter = 0
|
||||
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||
@@ -3,6 +3,7 @@ import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
|
||||
@@ -11,9 +12,16 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
|
||||
@router.post("")
|
||||
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
webhook_url = runtime.discord_webhook_url
|
||||
webhook_url = (
|
||||
getattr(runtime, "magent_notify_discord_webhook_url", None)
|
||||
or runtime.discord_webhook_url
|
||||
)
|
||||
if not webhook_url:
|
||||
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
||||
try:
|
||||
webhook_url = validate_notification_target_url(webhook_url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
feedback_type = str(payload.get("type") or "").strip().lower()
|
||||
if feedback_type not in {"bug", "feature"}:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..services.identity_review import confirm_identities, review_identities, resolve_identity, repair_identity
|
||||
from ..services.duplicate_accounts import repair_duplicates
|
||||
|
||||
router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
class Confirmation(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
user_ids: list[int] = Field(min_length=1, max_length=3000)
|
||||
|
||||
@field_validator("user_ids")
|
||||
@classmethod
|
||||
def unique_positive_ids(cls, value):
|
||||
if any(user_id <= 0 for user_id in value) or len(set(value)) != len(value):
|
||||
raise ValueError("Choose unique positive user IDs")
|
||||
return value
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def review(response: Response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
report, _, _ = await review_identities()
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/confirm")
|
||||
async def confirm(payload: Confirmation, response: Response, admin: dict = Depends(require_admin)):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await confirm_identities(payload.revision, payload.user_ids, admin)
|
||||
|
||||
|
||||
class Resolution(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
user_id: int = Field(gt=0, strict=True)
|
||||
jellyfin_user_id: str = Field(pattern=r"^[a-f0-9]{32}$")
|
||||
|
||||
|
||||
class ResolutionConfirmation(Resolution):
|
||||
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
@router.post("/resolve/check")
|
||||
async def check_resolution(payload: Resolution, response: Response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await resolve_identity(payload.user_id, payload.jellyfin_user_id)
|
||||
|
||||
|
||||
@router.post("/resolve/confirm")
|
||||
async def confirm_resolution(payload: ResolutionConfirmation, response: Response, admin: dict = Depends(require_admin)):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await resolve_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin)
|
||||
|
||||
|
||||
class RepairResolution(Resolution):
|
||||
create_seerr: bool = Field(default=False, strict=True)
|
||||
|
||||
|
||||
class RepairConfirmation(RepairResolution):
|
||||
revision: str = Field(pattern=r'^[a-f0-9]{64}$')
|
||||
|
||||
|
||||
@router.post('/repair/check')
|
||||
async def check_repair(payload: RepairResolution, response: Response):
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return await repair_identity(payload.user_id, payload.jellyfin_user_id, create_seerr=payload.create_seerr)
|
||||
|
||||
|
||||
@router.post('/repair/confirm')
|
||||
async def confirm_repair(payload: RepairConfirmation, response: Response, admin: dict = Depends(require_admin)):
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return await repair_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin, payload.create_seerr)
|
||||
|
||||
|
||||
class DuplicateCheck(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
user_id: int = Field(gt=0, strict=True)
|
||||
keep_id: int | None = Field(default=None, gt=0, strict=True)
|
||||
|
||||
|
||||
class DuplicateConfirmation(DuplicateCheck):
|
||||
keep_id: int = Field(gt=0, strict=True)
|
||||
revision: str = Field(pattern=r'^[a-f0-9]{64}$')
|
||||
|
||||
|
||||
@router.post('/duplicates/check')
|
||||
async def check_duplicates(payload: DuplicateCheck, response: Response):
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return await repair_duplicates(payload.user_id, payload.keep_id)
|
||||
|
||||
|
||||
@router.post('/duplicates/confirm')
|
||||
async def confirm_duplicates(payload: DuplicateConfirmation, response: Response, admin: dict = Depends(require_admin)):
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return await repair_duplicates(payload.user_id, payload.keep_id, payload.revision, admin)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from ..feature_guards import require_stats
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..clients.jellystat import HistoryLimitError, JellystatError
|
||||
from ..services.insights import get_insights
|
||||
from ..services.insights_artwork import get_artwork
|
||||
from ..services.monthly_reports import get_monthly_report, report_csv
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/insights", tags=["insights"], dependencies=[Depends(require_stats)])
|
||||
|
||||
|
||||
class MonthlyReportQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
month: str | None = Field(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$")
|
||||
|
||||
|
||||
async def monthly_data(user: dict, month: str | None) -> dict:
|
||||
try:
|
||||
return await get_monthly_report(user, month)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, "Choose the current month or one of the previous 23 months.") from exc
|
||||
except HistoryLimitError as exc:
|
||||
raise HTTPException(422, "This report exceeds Jellystat's history limit. No partial report has been generated.") from exc
|
||||
except JellystatError as exc:
|
||||
raise HTTPException(502, "Your monthly report is temporarily unavailable. Please try again shortly.") from exc
|
||||
|
||||
|
||||
@router.get("/reports/monthly")
|
||||
async def monthly_report(query: Annotated[MonthlyReportQuery, Query()], response: Response,
|
||||
user: dict = Depends(get_current_user)) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await monthly_data(user, query.month)
|
||||
|
||||
|
||||
@router.get("/reports/monthly.csv")
|
||||
async def monthly_export(query: Annotated[MonthlyReportQuery, Query()], user: dict = Depends(get_current_user)):
|
||||
report = await monthly_data(user, query.month)
|
||||
if report["state"] != "ready":
|
||||
raise HTTPException(409, "Connect Jellystat and link your viewing account before downloading a report.")
|
||||
return Response(report_csv(report), media_type="text/csv; charset=utf-8", headers={
|
||||
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
|
||||
"Content-Disposition": f'attachment; filename="magent-monthly-report-{report["month"]}.csv"'})
|
||||
|
||||
|
||||
@router.get("/artwork/{item_id}")
|
||||
async def artwork(item_id: str, token: Annotated[str, Query(max_length=100)], user: dict = Depends(get_current_user)):
|
||||
content, media_type = await get_artwork(user, get_runtime_settings(), item_id, token)
|
||||
return Response(content=content, media_type=media_type,
|
||||
headers={"Cache-Control": "private, max-age=600", "Vary": "Cookie, Authorization", "X-Content-Type-Options": "nosniff"})
|
||||
|
||||
|
||||
class InsightsQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
days: int = 30
|
||||
|
||||
@field_validator("days")
|
||||
@classmethod
|
||||
def supported_period(cls, value: int) -> int:
|
||||
if value not in {7, 30, 90, 365}:
|
||||
raise ValueError("Choose 7, 30, 90 or 365 days")
|
||||
return value
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def dashboard(query: Annotated[InsightsQuery, Query()], response: Response,
|
||||
user: dict = Depends(get_current_user)) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
try:
|
||||
return await get_insights(user, query.days)
|
||||
except HistoryLimitError as exc:
|
||||
raise HTTPException(status_code=422, detail="There is too much history for this period. Choose a shorter period.") from exc
|
||||
except JellystatError as exc:
|
||||
raise HTTPException(status_code=502, detail="Your viewing stats are temporarily unavailable. Please try again shortly.") from exc
|
||||
@@ -0,0 +1,193 @@
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
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
|
||||
from .recaps import StrictPayload, Preference, RecapSettings, TokenAction, no_cache
|
||||
|
||||
router = APIRouter(tags=['newsletters'], dependencies=[Depends(no_cache)])
|
||||
|
||||
|
||||
class Settings(StrictPayload):
|
||||
enabled: bool
|
||||
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(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__)
|
||||
|
||||
|
||||
class NewDraft(StrictPayload):
|
||||
days: Literal[7, 14, 30] = 7
|
||||
|
||||
|
||||
class Selection(StrictPayload):
|
||||
id: str = Field(pattern=r'^[a-f0-9]{32}$')
|
||||
selected: bool
|
||||
featured: bool
|
||||
|
||||
|
||||
class Version(StrictPayload):
|
||||
revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class EditionUpdate(Version):
|
||||
subject: str = Field(min_length=1, max_length=150)
|
||||
intro: str = Field(default='', max_length=2000)
|
||||
titles: list[Selection] = Field(max_length=60)
|
||||
|
||||
@field_validator('subject')
|
||||
@classmethod
|
||||
def subject_line(cls, value):
|
||||
value = value.strip()
|
||||
if not value or any(ord(char) < 32 or ord(char) == 127 for char in value):
|
||||
raise ValueError('Use a single, non-empty subject line.')
|
||||
return value
|
||||
|
||||
|
||||
class Test(Version):
|
||||
request_id: UUID
|
||||
|
||||
|
||||
class Publish(Version):
|
||||
send_at: datetime | None = None
|
||||
|
||||
|
||||
def fail(exc):
|
||||
if isinstance(exc, service.NewsletterError):
|
||||
raise HTTPException(exc.status, exc.detail) from exc
|
||||
if isinstance(exc, store.Conflict):
|
||||
raise HTTPException(429 if 'five minutes' in str(exc) else 409, str(exc)) from exc
|
||||
raise HTTPException(502, str(exc) if isinstance(exc, catalog.CatalogError) else 'Jellyfin took too long to prepare this edition. Please try again.') from exc
|
||||
|
||||
|
||||
@router.get('/profile/newsletters')
|
||||
def preference(user: dict = Depends(get_current_user)):
|
||||
try:
|
||||
return service.preferences(user)
|
||||
except service.NewsletterError as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.put('/profile/newsletters')
|
||||
async def set_preference(payload: Preference, user: dict = Depends(get_current_user)):
|
||||
try:
|
||||
if payload.enabled:
|
||||
return await service.subscribe(user)
|
||||
store.disable(service.account_for(user)['id'])
|
||||
return service.preferences(user)
|
||||
except service.NewsletterError as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.post('/newsletter-subscription/check')
|
||||
def check_token(payload: TokenAction):
|
||||
try:
|
||||
return service.token_action(payload.token, payload.action)
|
||||
except service.NewsletterError as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.post('/newsletter-subscription/confirm')
|
||||
def confirm_token(payload: TokenAction):
|
||||
try:
|
||||
return service.token_action(payload.token, payload.action, apply=True)
|
||||
except service.NewsletterError as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.get('/admin/newsletters')
|
||||
def overview(offset: int = Query(default=0, ge=0, le=1_000_000), user: dict = Depends(require_admin)):
|
||||
ready, detail = service.delivery_ready()
|
||||
return {'settings': store.public_settings(), 'ready': ready, 'detail': detail,
|
||||
'playback_url': service.playback_url(get_runtime_settings()), **store.overview(offset)}
|
||||
|
||||
|
||||
@router.put('/admin/newsletters')
|
||||
def settings(payload: Settings, user: dict = Depends(require_admin)):
|
||||
try:
|
||||
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(), "public_url": public_url}, datetime.now(timezone.utc))
|
||||
except (service.NewsletterError, store.Conflict) as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.post('/admin/newsletters/drafts', status_code=201)
|
||||
async def create_draft(payload: NewDraft, user: dict = Depends(require_admin)):
|
||||
try:
|
||||
return await service.create_draft(user, payload.days)
|
||||
except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.get('/admin/newsletters/editions/{identity}')
|
||||
def edition(identity: UUID, user: dict = Depends(require_admin)):
|
||||
try:
|
||||
return service.require_edition(identity.hex)
|
||||
except service.NewsletterError as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.put('/admin/newsletters/editions/{identity}')
|
||||
def update_edition(identity: UUID, payload: EditionUpdate, user: dict = Depends(require_admin)):
|
||||
try:
|
||||
return store.update_edition(identity.hex, payload.revision, payload.subject, payload.intro,
|
||||
[entry.model_dump() for entry in payload.titles], time.time())
|
||||
except store.Conflict as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.post('/admin/newsletters/editions/{identity}/preview')
|
||||
async def preview(identity: UUID, payload: Version, user: dict = Depends(require_admin)):
|
||||
try:
|
||||
return await service.preview(identity.hex, payload.revision)
|
||||
except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.post('/admin/newsletters/editions/{identity}/test', status_code=202)
|
||||
def send_test(identity: UUID, payload: Test, user: dict = Depends(require_admin)):
|
||||
try:
|
||||
return service.queue_test(user, identity.hex, payload.revision, str(payload.request_id))
|
||||
except (service.NewsletterError, store.Conflict) as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.post('/admin/newsletters/editions/{identity}/publish', status_code=202)
|
||||
def publish(identity: UUID, payload: Publish, user: dict = Depends(require_admin)):
|
||||
try:
|
||||
return service.publish(identity.hex, payload.revision, payload.send_at)
|
||||
except (service.NewsletterError, store.Conflict) as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.post('/admin/newsletters/editions/{identity}/cancel')
|
||||
def cancel(identity: UUID, user: dict = Depends(require_admin)):
|
||||
try:
|
||||
service.require_edition(identity.hex)
|
||||
return store.cancel(identity.hex, time.time())
|
||||
except service.NewsletterError as exc:
|
||||
fail(exc)
|
||||
|
||||
|
||||
@router.get('/admin/newsletters/artwork/{identity}')
|
||||
async def artwork(identity: UUID, user: dict = Depends(require_admin)):
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
|
||||
raise HTTPException(404, 'Artwork unavailable')
|
||||
content = await catalog.poster(runtime, identity.hex)
|
||||
if not content:
|
||||
raise HTTPException(404, 'Artwork unavailable')
|
||||
return Response(content=content, media_type='image/jpeg', headers={'Cache-Control': 'private, max-age=600'})
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..services.operation_progress import get_operation
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/operations",
|
||||
tags=["operations"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{operation_id}")
|
||||
async def operation_status(operation_id: str) -> dict:
|
||||
operation = get_operation(operation_id)
|
||||
if not operation:
|
||||
raise HTTPException(status_code=404, detail="Operation not found")
|
||||
return operation
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
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
|
||||
|
||||
|
||||
def no_cache(response: Response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
|
||||
router = APIRouter(tags=["email-recaps"], dependencies=[Depends(no_cache)])
|
||||
|
||||
|
||||
class StrictPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class Preference(StrictPayload):
|
||||
enabled: bool
|
||||
automatic_monthly: bool | None = Field(default=None, strict=True)
|
||||
|
||||
|
||||
class RecapSettings(StrictPayload):
|
||||
enabled: bool
|
||||
day: int = Field(ge=1, le=28)
|
||||
hour: int = Field(ge=0, le=23)
|
||||
public_url: str = Field(default="", max_length=500)
|
||||
|
||||
@field_validator("public_url")
|
||||
@classmethod
|
||||
def origin_only(cls, value: str) -> str:
|
||||
value = value.strip().rstrip('/')
|
||||
if not value:
|
||||
return value
|
||||
try:
|
||||
url = urlsplit(value)
|
||||
port = url.port
|
||||
except ValueError as exc:
|
||||
raise ValueError("Enter the public Magent address, such as https://magent.example.com.") from exc
|
||||
if (url.scheme not in {"http", "https"} or not url.hostname or url.username or url.password
|
||||
or url.path or url.query or url.fragment or any(char.isspace() or ord(char) < 33 for char in value)
|
||||
or any(char in value for char in '<>"\\') or (port is not None and port < 1)):
|
||||
raise ValueError("Enter a http(s) Magent address without a path, credentials or query.")
|
||||
return value
|
||||
|
||||
|
||||
class TestEmail(StrictPayload):
|
||||
month: str | None = Field(default=None, pattern=r"^[0-9]{4}-[0-9]{2}$")
|
||||
request_id: UUID
|
||||
|
||||
|
||||
class TokenAction(StrictPayload):
|
||||
token: str = Field(min_length=40, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
|
||||
action: Literal["confirm", "unsubscribe"]
|
||||
|
||||
|
||||
def error(exc: recaps.RecapError):
|
||||
raise HTTPException(exc.status, exc.detail) from exc
|
||||
|
||||
|
||||
@router.get("/profile/email-recaps")
|
||||
def preferences(user: dict = Depends(require_stats)) -> dict:
|
||||
try:
|
||||
return recaps.preferences(user)
|
||||
except recaps.RecapError as exc:
|
||||
error(exc)
|
||||
|
||||
|
||||
@router.put("/profile/email-recaps")
|
||||
async def preference(payload: Preference, user: dict = Depends(require_stats)) -> dict:
|
||||
try:
|
||||
if payload.enabled:
|
||||
return await recaps.subscribe(user, payload.automatic_monthly)
|
||||
store.disable(recaps.current_account(user)["id"])
|
||||
return recaps.preferences(user)
|
||||
except recaps.RecapError as exc:
|
||||
error(exc)
|
||||
|
||||
|
||||
@router.post("/email-recaps/check")
|
||||
def check_token(payload: TokenAction) -> dict:
|
||||
try:
|
||||
return recaps.token_action(payload.token, payload.action)
|
||||
except recaps.RecapError as exc:
|
||||
error(exc)
|
||||
|
||||
|
||||
@router.post("/email-recaps/confirm")
|
||||
def apply_token(payload: TokenAction) -> dict:
|
||||
try:
|
||||
return recaps.token_action(payload.token, payload.action, apply=True)
|
||||
except recaps.RecapError as exc:
|
||||
error(exc)
|
||||
|
||||
|
||||
@router.get("/admin/email-recaps")
|
||||
def overview(offset: int = Query(default=0, ge=0), user: dict = Depends(require_admin)) -> dict:
|
||||
ready, detail = recaps.delivery_ready()
|
||||
months = recaps.month_periods(None, datetime.now(timezone.utc))["available_months"][1:]
|
||||
return {"settings": store.settings(), "ready": ready, "detail": detail, "months": months,
|
||||
"worker_enabled": recaps.worker_enabled(), **store.history(offset=offset)}
|
||||
|
||||
|
||||
@router.put("/admin/email-recaps")
|
||||
def settings(payload: RecapSettings, user: dict = Depends(require_admin)) -> dict:
|
||||
if payload.enabled:
|
||||
# Validate against the proposed URL without writing any partial settings.
|
||||
ready, detail = recaps.smtp_email_config_ready()
|
||||
runtime = recaps.get_runtime_settings()
|
||||
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(), "public_url": magent_public_url(payload.public_url or store.settings()["public_url"])}, datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@router.get("/admin/email-recaps/preview")
|
||||
async def preview(month: str | None = Query(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$"), user: dict = Depends(require_admin)) -> dict:
|
||||
try:
|
||||
return await recaps.preview(user, month)
|
||||
except recaps.RecapError as exc:
|
||||
error(exc)
|
||||
|
||||
|
||||
@router.post("/admin/email-recaps/test", status_code=202)
|
||||
def test_email(payload: TestEmail, user: dict = Depends(require_admin)) -> dict:
|
||||
try:
|
||||
return recaps.queue_test(user, payload.month, str(payload.request_id))
|
||||
except recaps.RecapError as exc:
|
||||
error(exc)
|
||||
|
||||
|
||||
@router.post('/profile/email-recaps/send', status_code=202)
|
||||
def email_personal_report(payload: TestEmail, user: dict = Depends(require_stats)) -> dict:
|
||||
try:
|
||||
return recaps.queue_personal(user, payload.month, str(payload.request_id))
|
||||
except recaps.RecapError as exc:
|
||||
error(exc)
|
||||
+2416
-524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
"""Initial install bootstrap and authenticated setup wizard endpoints."""
|
||||
|
||||
from inspect import isawaitable
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import Field, SecretStr
|
||||
|
||||
from ..api_models import COMMON_ERROR_RESPONSES, StrictRequest
|
||||
from ..auth import _extract_client_ip, require_admin
|
||||
from ..services import setup as setup_service
|
||||
from ..installation_origin import normalize_application_origin
|
||||
from ..services.request_origins import can_claim_initial_origin
|
||||
|
||||
|
||||
router = APIRouter(prefix="/setup", tags=["setup"], responses=COMMON_ERROR_RESPONSES)
|
||||
|
||||
|
||||
class BootstrapRequest(StrictRequest):
|
||||
setup_token: SecretStr = Field(min_length=1, max_length=1024)
|
||||
username: str = Field(min_length=1, max_length=100)
|
||||
password: SecretStr = Field(min_length=1, max_length=1024)
|
||||
application_url: str | None = Field(default=None, max_length=2048)
|
||||
|
||||
|
||||
class SetupProgress(StrictRequest):
|
||||
step: setup_service.SetupStep
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def public_status(response: Response) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return setup_service.get_public_setup_status()
|
||||
|
||||
|
||||
@router.post("/bootstrap", status_code=201)
|
||||
def bootstrap(payload: BootstrapRequest, request: Request) -> dict:
|
||||
status = setup_service.get_public_setup_status()
|
||||
if not status["needs_admin"]:
|
||||
raise HTTPException(status_code=409, detail="Initial administrator setup is no longer available.")
|
||||
retry_after = setup_service.consume_bootstrap_attempt(_extract_client_ip(request))
|
||||
if retry_after is not None:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many setup attempts. Try again later.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
try:
|
||||
application_url = payload.application_url
|
||||
if application_url is not None:
|
||||
application_url = normalize_application_origin(application_url)
|
||||
origin = request.headers.get("origin", "")
|
||||
if not origin or application_url != normalize_application_origin(origin):
|
||||
raise HTTPException(status_code=403, detail="The site address must match the address open in your browser.")
|
||||
elif can_claim_initial_origin():
|
||||
raise HTTPException(status_code=400, detail="Confirm the application URL to create the administrator.")
|
||||
setup_service.bootstrap_administrator(
|
||||
payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value(),
|
||||
application_url=application_url,
|
||||
)
|
||||
except setup_service.InvalidSetupTokenError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except setup_service.SetupUnavailableError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"status": "created", "username": payload.username.strip()}
|
||||
|
||||
|
||||
@router.get("/state", dependencies=[Depends(require_admin)])
|
||||
def get_state() -> dict:
|
||||
return setup_service.get_setup_state()
|
||||
|
||||
|
||||
@router.put("/state", dependencies=[Depends(require_admin)])
|
||||
def update_state(payload: SetupProgress) -> dict:
|
||||
return setup_service.update_setup_step(payload.step)
|
||||
|
||||
|
||||
@router.post("/complete", dependencies=[Depends(require_admin)])
|
||||
async def finish_setup(request: Request) -> dict:
|
||||
try:
|
||||
state = setup_service.complete_setup()
|
||||
except setup_service.SetupUnavailableError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
# Startup owns worker lifecycle. Its callback must be idempotent so retries
|
||||
# after a network interruption cannot start duplicate import/automation jobs.
|
||||
callback = getattr(request.app.state, "on_setup_complete", None)
|
||||
if callback is not None:
|
||||
result = callback()
|
||||
if isawaitable(result):
|
||||
await result
|
||||
return state
|
||||
@@ -1,8 +1,11 @@
|
||||
from typing import Any, Dict
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
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"])
|
||||
@@ -13,19 +16,39 @@ _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"
|
||||
info = {
|
||||
"buildNumber": (runtime.site_build_number or "").strip(),
|
||||
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
|
||||
"banner": {
|
||||
"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),
|
||||
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
||||
},
|
||||
"navigation": {
|
||||
"showRequests": bool(runtime.site_nav_show_requests),
|
||||
},
|
||||
}
|
||||
if include_changelog:
|
||||
info["changelog"] = (runtime.site_changelog or "").strip()
|
||||
info["changelog"] = (CHANGELOG or "").strip()
|
||||
playback_url = (runtime.jellyfin_public_url or "").strip()
|
||||
try:
|
||||
parsed = urlsplit(playback_url)
|
||||
valid = parsed.scheme in {"http", "https"} and bool(parsed.hostname) and not parsed.username and not parsed.password
|
||||
except ValueError:
|
||||
valid = False
|
||||
info["mediaServerUrl"] = playback_url if valid else None
|
||||
return info
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,18 @@ from typing import Any, Dict
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..auth import require_admin
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..clients.radarr import RadarrClient
|
||||
from ..clients.bazarr import BazarrClient
|
||||
from ..clients.prowlarr import ProwlarrClient
|
||||
from ..clients.qbittorrent import QBittorrentClient
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellystat import JellystatClient
|
||||
|
||||
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
|
||||
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
||||
@@ -26,12 +28,42 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
||||
return {"name": name, "status": "down", "message": str(exc)}
|
||||
|
||||
|
||||
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
|
||||
if not qbittorrent.base_url:
|
||||
return {"name": "qBittorrent", "status": "not_configured"}
|
||||
if not qbittorrent.username or not qbittorrent.password:
|
||||
reachable = await qbittorrent.is_webui_reachable()
|
||||
return {
|
||||
"name": "qBittorrent",
|
||||
"status": "degraded" if reachable else "not_configured",
|
||||
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
|
||||
}
|
||||
try:
|
||||
result = await qbittorrent.get_app_version()
|
||||
return {"name": "qBittorrent", "status": "up", "detail": result}
|
||||
except RuntimeError as exc:
|
||||
if "login failed" in str(exc).lower():
|
||||
reachable = await qbittorrent.is_webui_reachable()
|
||||
if reachable:
|
||||
return {
|
||||
"name": "qBittorrent",
|
||||
"status": "degraded",
|
||||
"message": "qBittorrent is reachable but the saved credentials were rejected",
|
||||
}
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
except Exception as exc:
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
async def services_status() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
qbittorrent = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||
@@ -41,7 +73,7 @@ async def services_status() -> Dict[str, Any]:
|
||||
services = []
|
||||
services.append(
|
||||
await _check(
|
||||
"Jellyseerr",
|
||||
"Seerr",
|
||||
jellyseerr.configured(),
|
||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||
)
|
||||
@@ -60,6 +92,13 @@ async def services_status() -> Dict[str, Any]:
|
||||
radarr.get_system_status,
|
||||
)
|
||||
)
|
||||
services.append(
|
||||
await _check(
|
||||
"Bazarr",
|
||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
||||
bazarr.get_system_status,
|
||||
)
|
||||
)
|
||||
prowlarr_status = await _check(
|
||||
"Prowlarr",
|
||||
prowlarr.configured(),
|
||||
@@ -71,13 +110,7 @@ async def services_status() -> Dict[str, Any]:
|
||||
prowlarr_status["status"] = "degraded"
|
||||
prowlarr_status["message"] = "Health warnings"
|
||||
services.append(prowlarr_status)
|
||||
services.append(
|
||||
await _check(
|
||||
"qBittorrent",
|
||||
qbittorrent.configured(),
|
||||
qbittorrent.get_app_version,
|
||||
)
|
||||
)
|
||||
services.append(await _check_qbittorrent(qbittorrent))
|
||||
services.append(
|
||||
await _check(
|
||||
"Jellyfin",
|
||||
@@ -86,6 +119,11 @@ async def services_status() -> Dict[str, Any]:
|
||||
)
|
||||
)
|
||||
|
||||
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
# Optional analytics must not degrade the media pipeline when not configured.
|
||||
if jellystat.configured():
|
||||
services.append(await _check("Jellystat", True, jellystat.test_connection))
|
||||
|
||||
overall = "up"
|
||||
if any(s.get("status") == "down" for s in services):
|
||||
overall = "down"
|
||||
@@ -101,6 +139,7 @@ async def test_service(service: str) -> Dict[str, Any]:
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
qbittorrent = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||
@@ -108,19 +147,34 @@ async def test_service(service: str) -> Dict[str, Any]:
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
|
||||
service_key = service.strip().lower()
|
||||
if service_key == "jellystat":
|
||||
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
return await _check("Jellystat", jellystat.configured(), jellystat.test_connection)
|
||||
checks = {
|
||||
"seerr": (
|
||||
"Seerr",
|
||||
jellyseerr.configured(),
|
||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||
),
|
||||
"jellyseerr": (
|
||||
"Jellyseerr",
|
||||
"Seerr",
|
||||
jellyseerr.configured(),
|
||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||
),
|
||||
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
||||
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
||||
"bazarr": (
|
||||
"Bazarr",
|
||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
||||
bazarr.get_system_status,
|
||||
),
|
||||
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
||||
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
|
||||
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
||||
}
|
||||
|
||||
if service_key == "qbittorrent":
|
||||
return await _check_qbittorrent(qbittorrent)
|
||||
|
||||
if service_key not in checks:
|
||||
raise HTTPException(status_code=404, detail="Unknown service")
|
||||
|
||||
|
||||
@@ -2,17 +2,47 @@ from .config import settings
|
||||
from .db import get_settings_overrides
|
||||
|
||||
_INT_FIELDS = {
|
||||
"magent_application_port",
|
||||
"magent_api_port",
|
||||
"auth_rate_limit_window_seconds",
|
||||
"auth_rate_limit_max_attempts_ip",
|
||||
"auth_rate_limit_max_attempts_user",
|
||||
"password_reset_rate_limit_window_seconds",
|
||||
"password_reset_rate_limit_max_attempts_ip",
|
||||
"password_reset_rate_limit_max_attempts_identifier",
|
||||
"sonarr_quality_profile_id",
|
||||
"radarr_quality_profile_id",
|
||||
"jwt_exp_minutes",
|
||||
"log_file_max_bytes",
|
||||
"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",
|
||||
"issue_confirmation_interval_value",
|
||||
"magent_notify_email_smtp_port",
|
||||
}
|
||||
_BOOL_FIELDS = {
|
||||
"magent_proxy_enabled",
|
||||
"magent_proxy_trust_forwarded_headers",
|
||||
"magent_ssl_bind_enabled",
|
||||
"magent_notify_enabled",
|
||||
"magent_notify_email_enabled",
|
||||
"magent_notify_email_use_tls",
|
||||
"magent_notify_email_use_ssl",
|
||||
"magent_notify_discord_enabled",
|
||||
"magent_notify_telegram_enabled",
|
||||
"magent_notify_push_enabled",
|
||||
"magent_notify_webhook_enabled",
|
||||
"jellyfin_sync_to_arr",
|
||||
"site_banner_enabled",
|
||||
"site_login_show_jellyfin_login",
|
||||
"site_login_show_local_login",
|
||||
"site_login_show_forgot_password",
|
||||
"site_login_show_signup_link",
|
||||
"site_nav_show_requests",
|
||||
}
|
||||
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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", "discord_webhook_url",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
+84
-8
@@ -1,13 +1,23 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
import jwt
|
||||
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 = 12
|
||||
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
@@ -15,18 +25,84 @@ 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 create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
||||
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:
|
||||
candidate = password.strip()
|
||||
if len(candidate) < MIN_PASSWORD_LENGTH:
|
||||
raise ValueError(PASSWORD_POLICY_MESSAGE)
|
||||
return candidate
|
||||
|
||||
|
||||
def _create_token(
|
||||
subject: str,
|
||||
role: str,
|
||||
*,
|
||||
expires_at: datetime,
|
||||
token_type: str = "access",
|
||||
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,
|
||||
*,
|
||||
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)
|
||||
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||
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,
|
||||
*,
|
||||
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", auth_version=auth_version)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Dict[str, Any]:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
|
||||
if not settings.jwt_secret:
|
||||
raise ValueError("JWT_SECRET is not configured")
|
||||
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):
|
||||
@@ -36,5 +112,5 @@ class TokenError(Exception):
|
||||
def safe_decode_token(token: str) -> Dict[str, Any]:
|
||||
try:
|
||||
return decode_token(token)
|
||||
except JWTError as exc:
|
||||
except InvalidTokenError as exc:
|
||||
raise TokenError("Invalid token") from exc
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,647 @@
|
||||
"""Encrypted, portable backups and restart-only SQLite restores.
|
||||
|
||||
Restore is deliberately a two-step operation: the authenticated request validates
|
||||
and stages it, then a single backend process applies it before opening the DB.
|
||||
A durable journal and a private rollback copy protect interrupted installations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import closing, contextmanager
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, BinaryIO, Iterator
|
||||
import uuid
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from ..config import Settings, settings
|
||||
from ..db import _db_path
|
||||
from ..installation_origin import managed_runtime, normalize_application_origin
|
||||
from ..schema_migrations import MIGRATIONS
|
||||
from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value
|
||||
|
||||
FORMAT_VERSION = 1
|
||||
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
|
||||
MAX_EXPANDED_BYTES = 128 * 1024 * 1024
|
||||
MAX_ENTRIES = 20_000
|
||||
MAGIC = b"MAGENT-BACKUP\x00\x01"
|
||||
_LOCK = threading.Lock()
|
||||
_ASSET_NAME = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
_TMDB_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
||||
# Host identity, process controls and local file locations belong to the target.
|
||||
_LOCAL_FIELDS = {
|
||||
"sqlite_path", "sqlite_journal_mode", "jwt_secret", "settings_encryption_key",
|
||||
"admin_username", "admin_password", "setup_token", "app_name", "cors_allow_origin",
|
||||
"auth_cookie_name", "auth_cookie_secure", "auth_cookie_samesite", "auth_cookie_domain",
|
||||
"auth_state_cookie_name", "jwt_issuer", "jwt_audience", "api_docs_enabled",
|
||||
"log_file", "magent_application_port", "magent_api_port", "magent_bind_host",
|
||||
"magent_proxy_trusted_proxies", "magent_proxy_trust_forwarded_headers",
|
||||
"magent_ssl_bind_enabled", "magent_ssl_certificate_path", "magent_ssl_private_key_path",
|
||||
"magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
|
||||
"site_build_number", "site_changelog", "magent_allow_private_notification_targets",
|
||||
}
|
||||
|
||||
|
||||
class BackupError(ValueError):
|
||||
"""A safe-to-display backup validation or state error."""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _assets_root() -> Path:
|
||||
# Matches the image and branding routers, independently of SQLITE_PATH.
|
||||
return Path.cwd() / "data"
|
||||
|
||||
|
||||
def _control_root() -> Path:
|
||||
return Path(_db_path()).absolute().parent / "backups"
|
||||
|
||||
|
||||
def _private_dir(path: Path) -> None:
|
||||
if path.is_symlink():
|
||||
raise BackupError("Backup directories must not be symbolic links")
|
||||
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
def _write_private(path: Path, content: bytes) -> None:
|
||||
with path.open("xb") as handle:
|
||||
path.chmod(0o600)
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict) -> None:
|
||||
temporary = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex)
|
||||
try:
|
||||
_write_private(temporary, json.dumps(data, separators=(",", ":")).encode())
|
||||
os.replace(temporary, path)
|
||||
_sync_directory(path.parent)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _sync_directory(path: Path) -> None:
|
||||
if os.name != "nt":
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _sync_tree(path: Path) -> None:
|
||||
for parent, _directories, files in os.walk(path, topdown=False):
|
||||
for filename in files:
|
||||
with (Path(parent) / filename).open("r+b") as handle:
|
||||
os.fsync(handle.fileno())
|
||||
_sync_directory(Path(parent))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_operation() -> Iterator[None]:
|
||||
if not _LOCK.acquire(blocking=False):
|
||||
raise BackupError("Another backup or restore operation is in progress")
|
||||
handle = None
|
||||
locked = False
|
||||
try:
|
||||
root = _control_root()
|
||||
_private_dir(root)
|
||||
handle = (root / "operation.lock").open("a+b")
|
||||
os.chmod(handle.name, 0o600)
|
||||
# OS locks are released even if a process crashes; support the dev host too.
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
handle.seek(0)
|
||||
if not handle.read(1):
|
||||
handle.write(b"0")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
try:
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except OSError as exc:
|
||||
raise BackupError("Another backup or restore operation is in progress") from exc
|
||||
else:
|
||||
import fcntl
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
raise BackupError("Another backup or restore operation is in progress") from exc
|
||||
locked = True
|
||||
yield
|
||||
finally:
|
||||
if handle is not None:
|
||||
if locked:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
handle.close()
|
||||
_LOCK.release()
|
||||
|
||||
|
||||
def validate_passphrase(passphrase: str) -> None:
|
||||
if not isinstance(passphrase, str) or not 12 <= len(passphrase) <= 1024:
|
||||
raise BackupError("Use a backup passphrase between 12 and 1024 characters")
|
||||
|
||||
|
||||
def _key(passphrase: str, salt: bytes) -> bytes:
|
||||
validate_passphrase(passphrase)
|
||||
return Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode("utf-8"))
|
||||
|
||||
|
||||
def _encrypt(content: bytes, passphrase: str) -> bytes:
|
||||
salt, nonce = os.urandom(16), os.urandom(12)
|
||||
header = MAGIC + salt + nonce
|
||||
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, content, header)
|
||||
|
||||
|
||||
def _decrypt(content: bytes, passphrase: str) -> bytes:
|
||||
header_size = len(MAGIC) + 28
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
raise BackupError("Backup exceeds the 32 MiB upload limit")
|
||||
if len(content) < header_size + 16 or not content.startswith(MAGIC):
|
||||
raise BackupError("This is not a supported encrypted Magent backup")
|
||||
salt = content[len(MAGIC):len(MAGIC) + 16]
|
||||
nonce = content[len(MAGIC) + 16:header_size]
|
||||
try:
|
||||
return AESGCM(_key(passphrase, salt)).decrypt(nonce, content[header_size:], content[:header_size])
|
||||
except InvalidTag as exc:
|
||||
raise BackupError("Incorrect passphrase or damaged backup") from exc
|
||||
|
||||
|
||||
def _database_copy(source: Path, destination: Path) -> None:
|
||||
if not source.is_file() or source.is_symlink():
|
||||
raise BackupError("The configured database is unavailable or is a symbolic link")
|
||||
deadline = time.monotonic() + 60
|
||||
|
||||
def progress(_status: int, _remaining: int, _total: int) -> None:
|
||||
if time.monotonic() > deadline:
|
||||
raise BackupError("Database is too busy to back up; try again shortly")
|
||||
|
||||
with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)) as src:
|
||||
with closing(sqlite3.connect(destination)) as dst:
|
||||
destination.chmod(0o600)
|
||||
src.backup(dst, pages=256, progress=progress, sleep=0.05)
|
||||
dst.execute("PRAGMA journal_mode=DELETE")
|
||||
|
||||
|
||||
def _portable_database(path: Path) -> None:
|
||||
"""Materialize env-backed settings and remove source-specific encryption."""
|
||||
with closing(sqlite3.connect(path)) as conn, conn:
|
||||
conn.execute("PRAGMA secure_delete=ON")
|
||||
# init_db recreates application-owned triggers after restoration; never
|
||||
# distribute executable schema objects in a data backup.
|
||||
for (trigger,) in conn.execute("SELECT name FROM sqlite_master WHERE type='trigger'").fetchall():
|
||||
quoted = str(trigger).replace('"', '""')
|
||||
conn.execute(f'DROP TRIGGER "{quoted}"')
|
||||
overrides = dict(conn.execute("SELECT key, value FROM settings"))
|
||||
for key, default in settings.model_dump().items():
|
||||
if key in _LOCAL_FIELDS:
|
||||
continue
|
||||
value = overrides.get(key)
|
||||
value = default if value is None else decrypt_setting_value(key, value)
|
||||
conn.execute(
|
||||
"INSERT INTO settings(key,value,updated_at) VALUES (?,?,?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
|
||||
(key, "" if value is None else str(value), _now()),
|
||||
)
|
||||
for key in _LOCAL_FIELDS:
|
||||
conn.execute("DELETE FROM settings WHERE key=?", (key,))
|
||||
# Future secret keys may not yet be exposed through Settings.
|
||||
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
|
||||
if key in SENSITIVE_SETTING_KEYS:
|
||||
conn.execute("UPDATE settings SET value=? WHERE key=?", (decrypt_setting_value(key, value), key))
|
||||
conn.commit()
|
||||
conn.execute("VACUUM")
|
||||
|
||||
|
||||
def _asset_allowed(name: str, include_cache: bool) -> bool:
|
||||
parts = PurePosixPath(name).parts
|
||||
if name in {"files/branding/logo.png", "files/branding/favicon.ico"}:
|
||||
return True
|
||||
return bool(
|
||||
include_cache and len(parts) == 5 and parts[:3] == ("files", "artwork", "tmdb")
|
||||
and parts[3] in _TMDB_SIZES and _ASSET_NAME.fullmatch(parts[4])
|
||||
and parts[4] not in {".", ".."}
|
||||
)
|
||||
|
||||
|
||||
def _asset_files(include_cache: bool) -> Iterator[tuple[Path, str]]:
|
||||
root = _assets_root()
|
||||
for directory in ("branding", "artwork") if include_cache else ("branding",):
|
||||
base = root / directory
|
||||
if not base.exists():
|
||||
continue
|
||||
if base.is_symlink() or root.is_symlink():
|
||||
raise BackupError("Asset directories must not be symbolic links")
|
||||
for parent, directories, files in os.walk(base, followlinks=False):
|
||||
if any((Path(parent) / name).is_symlink() for name in directories + files):
|
||||
raise BackupError("Symbolic links are not supported in backup assets")
|
||||
for filename in files:
|
||||
path = Path(parent) / filename
|
||||
archive_name = "files/" + path.relative_to(root).as_posix()
|
||||
if _asset_allowed(archive_name, include_cache):
|
||||
yield path, archive_name
|
||||
|
||||
|
||||
def create_backup(passphrase: str, include_cache: bool = False) -> tuple[bytes, str]:
|
||||
validate_passphrase(passphrase)
|
||||
with _exclusive_operation(), tempfile.TemporaryDirectory(prefix="export-", dir=_control_root()) as temporary:
|
||||
directory = Path(temporary)
|
||||
directory.chmod(0o700)
|
||||
database = directory / "database.sqlite3"
|
||||
_database_copy(Path(_db_path()).absolute(), database)
|
||||
_portable_database(database)
|
||||
files = [(database, "database.sqlite3"), *_asset_files(include_cache)]
|
||||
if len(files) > MAX_ENTRIES - 1 or sum(path.stat().st_size for path, _ in files) > MAX_EXPANDED_BYTES:
|
||||
raise BackupError("Backup is too large; retry without the artwork cache")
|
||||
archive_path = directory / "payload.zip"
|
||||
manifest = {
|
||||
"format_version": FORMAT_VERSION, "created_at": _now(),
|
||||
"build": str(settings.site_build_number or "unknown"), "include_cache": include_cache,
|
||||
"files": {},
|
||||
}
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
||||
archive_path.chmod(0o600)
|
||||
total = 0
|
||||
for path, name in files:
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
with path.open("rb") as source, archive.open(name, "w") as destination:
|
||||
while chunk := source.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
size += len(chunk)
|
||||
if total > MAX_EXPANDED_BYTES:
|
||||
raise BackupError("Backup is too large; retry without the artwork cache")
|
||||
digest.update(chunk)
|
||||
destination.write(chunk)
|
||||
manifest["files"][name] = {"bytes": size, "sha256": digest.hexdigest()}
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
if archive_path.stat().st_size > MAX_UPLOAD_BYTES - 128:
|
||||
raise BackupError("Backup exceeds the 32 MiB limit; retry without the artwork cache")
|
||||
encrypted = _encrypt(archive_path.read_bytes(), passphrase)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
return encrypted, f"magent-backup-{stamp}.magent-backup"
|
||||
|
||||
|
||||
def _validate_database(path: Path, *, verify_settings_encryption: bool = False) -> None:
|
||||
try:
|
||||
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as conn:
|
||||
conn.execute("PRAGMA trusted_schema=OFF")
|
||||
deadline = time.monotonic() + 30
|
||||
conn.set_progress_handler(lambda: int(time.monotonic() > deadline), 10_000)
|
||||
if conn.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
|
||||
raise BackupError("Backup database failed its integrity check")
|
||||
schema = conn.execute("SELECT type,name,sql FROM sqlite_master").fetchall()
|
||||
if len(schema) > 500 or any(
|
||||
kind in {"trigger", "view"} or "VIRTUAL TABLE" in str(sql).upper()
|
||||
for kind, _name, sql in schema
|
||||
):
|
||||
raise BackupError("Backup contains an unsupported database schema")
|
||||
if conn.execute("PRAGMA foreign_key_check").fetchone() is not None:
|
||||
raise BackupError("Backup database contains broken references")
|
||||
required = {
|
||||
"settings": {"key", "value", "updated_at"},
|
||||
"users": {"id", "username", "password_hash", "role", "is_blocked", "auth_version"},
|
||||
"signup_invites": {"id", "code", "enabled"},
|
||||
"requests_cache": {"request_id", "payload_json"},
|
||||
"schema_migrations": {"version", "name", "applied_at"},
|
||||
"password_reset_tokens": {"id", "token_hash"},
|
||||
}
|
||||
for table, fields in required.items():
|
||||
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
|
||||
if not fields <= columns:
|
||||
raise BackupError("Backup does not contain a compatible Magent database")
|
||||
optional = {
|
||||
"installation_setup": {"id", "completed", "step", "completed_at"},
|
||||
"installation_setup_attempts": {"scope", "key_hash", "occurred_at"},
|
||||
}
|
||||
table_names = {name for kind, name, _sql in schema if kind == "table"}
|
||||
for table, fields in optional.items():
|
||||
if table in table_names:
|
||||
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
|
||||
if not fields <= columns:
|
||||
raise BackupError("Backup setup state has an incompatible schema")
|
||||
# An admin can stage a restore only after target initialization. Its
|
||||
# schema is a trusted reference for *all* runtime columns, including
|
||||
# versioned migrations that init_db will not rerun on a restored DB.
|
||||
target = Path(_db_path()).absolute()
|
||||
if target.is_file() and target != path:
|
||||
with closing(sqlite3.connect(target.as_uri() + "?mode=ro", uri=True)) as reference:
|
||||
tables = [row[0] for row in reference.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
for table in tables:
|
||||
if table.startswith("sqlite_") or table in {"installation_setup", "installation_setup_attempts"}:
|
||||
continue
|
||||
quoted = str(table).replace('"', '""')
|
||||
expected = {
|
||||
row[1]: (row[2].upper(), bool(row[3]), row[5])
|
||||
for row in reference.execute(f'PRAGMA table_info("{quoted}")')
|
||||
}
|
||||
actual = {
|
||||
row[1]: (row[2].upper(), bool(row[3]), row[5])
|
||||
for row in conn.execute(f'PRAGMA table_info("{quoted}")')
|
||||
}
|
||||
if expected != actual:
|
||||
raise BackupError("Backup is missing database columns required by this installation")
|
||||
versions = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
|
||||
if versions != {migration.version for migration in MIGRATIONS}:
|
||||
raise BackupError("Backup schema is incompatible; restore using the same Magent version")
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM users WHERE role='admin' AND is_blocked=0 AND password_hash IS NOT NULL LIMIT 1"
|
||||
).fetchone():
|
||||
raise BackupError("Backup must contain an active administrator account")
|
||||
values = dict(conn.execute("SELECT key,value FROM settings"))
|
||||
if _LOCAL_FIELDS.intersection(values):
|
||||
raise BackupError("Backup contains host-specific configuration")
|
||||
# Pydantic checks the types of portable settings without reading env values.
|
||||
for key, value in values.items():
|
||||
if verify_settings_encryption and key in SENSITIVE_SETTING_KEYS:
|
||||
value = decrypt_setting_value(key, value)
|
||||
if key in Settings.model_fields and value not in {None, ""}:
|
||||
field = Settings.model_fields[key]
|
||||
TypeAdapter(field.rebuild_annotation()).validate_python(value)
|
||||
except (sqlite3.DatabaseError, TypeError, ValueError, RuntimeError) as exc:
|
||||
if isinstance(exc, BackupError):
|
||||
raise
|
||||
raise BackupError("Backup database or configuration is invalid") from exc
|
||||
|
||||
|
||||
def _extract_archive(payload: bytes, directory: Path) -> dict[str, Any]:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||
entries = archive.infolist()
|
||||
if not entries or len(entries) > MAX_ENTRIES:
|
||||
raise BackupError("Backup contains too many files")
|
||||
names = [entry.filename for entry in entries]
|
||||
if len(set(names)) != len(names) or "manifest.json" not in names or "database.sqlite3" not in names:
|
||||
raise BackupError("Backup manifest is missing or contains duplicate files")
|
||||
if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES:
|
||||
raise BackupError("Expanded backup exceeds the 128 MiB limit")
|
||||
for entry in entries:
|
||||
parts = PurePosixPath(entry.filename).parts
|
||||
mode = entry.external_attr >> 16
|
||||
if (
|
||||
entry.is_dir() or entry.filename.startswith("/") or "\\" in entry.filename
|
||||
or str(PurePosixPath(entry.filename)) != entry.filename
|
||||
or ":" in entry.filename or any(part in {".", ".."} for part in parts)
|
||||
or (stat.S_IFMT(mode) not in {0, stat.S_IFREG}) or entry.flag_bits & 1
|
||||
or entry.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
|
||||
):
|
||||
raise BackupError("Backup contains an unsafe archive entry")
|
||||
if archive.getinfo("manifest.json").file_size > 4 * 1024 * 1024:
|
||||
raise BackupError("Backup manifest is too large")
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
if (
|
||||
not isinstance(manifest, dict) or manifest.get("format_version") != FORMAT_VERSION
|
||||
or not isinstance(manifest.get("include_cache"), bool)
|
||||
or not isinstance(manifest.get("created_at"), str) or len(manifest["created_at"]) > 64
|
||||
or not isinstance(manifest.get("build"), str) or len(manifest["build"]) > 100
|
||||
or not isinstance(manifest.get("files"), dict)
|
||||
or set(manifest["files"]) != set(names) - {"manifest.json"}
|
||||
):
|
||||
raise BackupError("Backup manifest is invalid or unsupported")
|
||||
extracted_bytes = 0
|
||||
for entry in entries:
|
||||
name = entry.filename
|
||||
if name == "manifest.json":
|
||||
continue
|
||||
if name != "database.sqlite3" and not _asset_allowed(name, manifest["include_cache"]):
|
||||
raise BackupError("Backup contains an unsupported file")
|
||||
expected = manifest["files"][name]
|
||||
if not isinstance(expected, dict) or expected.get("bytes") != entry.file_size:
|
||||
raise BackupError("Backup file does not match its manifest")
|
||||
target = directory.joinpath(*PurePosixPath(name).parts)
|
||||
_private_dir(target.parent)
|
||||
digest = hashlib.sha256()
|
||||
with archive.open(entry) as source, target.open("xb") as destination:
|
||||
target.chmod(0o600)
|
||||
while chunk := source.read(1024 * 1024):
|
||||
extracted_bytes += len(chunk)
|
||||
if extracted_bytes > MAX_EXPANDED_BYTES:
|
||||
raise BackupError("Expanded backup exceeds the 128 MiB limit")
|
||||
digest.update(chunk)
|
||||
destination.write(chunk)
|
||||
destination.flush()
|
||||
os.fsync(destination.fileno())
|
||||
if digest.hexdigest() != expected.get("sha256"):
|
||||
raise BackupError("Backup file failed its checksum")
|
||||
_validate_database(directory / "database.sqlite3")
|
||||
return manifest
|
||||
except (zipfile.BadZipFile, KeyError, TypeError, ValueError, RuntimeError, zlib.error) as exc:
|
||||
if isinstance(exc, BackupError):
|
||||
raise
|
||||
raise BackupError("Backup archive is invalid or damaged") from exc
|
||||
|
||||
|
||||
def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
|
||||
validate_passphrase(passphrase)
|
||||
with _exclusive_operation():
|
||||
root = _control_root()
|
||||
pending = root / "pending"
|
||||
if pending.exists():
|
||||
raise BackupError("A restore is already staged; cancel it before uploading another")
|
||||
payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase)
|
||||
destination_origin = None
|
||||
if managed_runtime():
|
||||
from .public_urls import magent_public_url
|
||||
try:
|
||||
destination_origin = normalize_application_origin(magent_public_url())
|
||||
except ValueError:
|
||||
raise BackupError("Configure a valid destination application address before restoring a backup") from None
|
||||
with tempfile.TemporaryDirectory(prefix="validate-", dir=root) as temporary:
|
||||
stage = Path(temporary)
|
||||
stage.chmod(0o700)
|
||||
manifest = _extract_archive(payload, stage)
|
||||
with closing(sqlite3.connect(stage / "database.sqlite3")) as conn, conn:
|
||||
conn.execute("PRAGMA secure_delete=ON")
|
||||
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
|
||||
if key in SENSITIVE_SETTING_KEYS:
|
||||
if value and str(value).startswith("enc:v1:"):
|
||||
raise BackupError("Backup settings are not portable")
|
||||
conn.execute("UPDATE settings SET value=? WHERE key=?", (encrypt_setting_value(key, value), key))
|
||||
if destination_origin is not None:
|
||||
# The backup's hostname must not replace this installation's
|
||||
# trusted browser origin or change its cookie policy.
|
||||
conn.execute(
|
||||
"INSERT INTO settings(key,value,updated_at) VALUES ('magent_application_url',?,?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
|
||||
(destination_origin, _now()),
|
||||
)
|
||||
# Do not revive reset links or existing browser sessions. Invites remain intact.
|
||||
conn.execute("DELETE FROM password_reset_tokens")
|
||||
conn.execute("UPDATE users SET auth_version=?", (secrets.randbelow(2**52) + 1_000_000,))
|
||||
if not manifest["include_cache"]:
|
||||
conn.execute("UPDATE artwork_cache_status SET poster_cached=0,backdrop_cached=0")
|
||||
conn.commit()
|
||||
# Remove plaintext secret remnants from replaced/free SQLite pages.
|
||||
conn.execute("VACUUM")
|
||||
metadata = {key: manifest[key] for key in ("created_at", "build", "include_cache")}
|
||||
metadata["staged_at"] = _now()
|
||||
_write_json(stage / "metadata.json", metadata)
|
||||
# Stage survives reboot; it contains only secrets encrypted for this installation.
|
||||
os.replace(stage, pending)
|
||||
_sync_directory(root)
|
||||
return metadata
|
||||
|
||||
|
||||
def backup_status() -> dict[str, Any]:
|
||||
root = _control_root()
|
||||
pending_path = root / "pending" / "metadata.json"
|
||||
last_path = root / "last-restore.json"
|
||||
return {
|
||||
"format_version": FORMAT_VERSION, "max_upload_bytes": MAX_UPLOAD_BYTES,
|
||||
"max_expanded_bytes": MAX_EXPANDED_BYTES,
|
||||
"include_cache_default": False,
|
||||
"pending_restore": json.loads(pending_path.read_text()) if pending_path.is_file() else None,
|
||||
"last_restore": json.loads(last_path.read_text()) if last_path.is_file() else None,
|
||||
}
|
||||
|
||||
|
||||
def cancel_restore() -> None:
|
||||
with _exclusive_operation():
|
||||
pending = _control_root() / "pending"
|
||||
if pending.is_symlink():
|
||||
raise BackupError("Invalid staged restore directory")
|
||||
if pending.exists():
|
||||
shutil.rmtree(pending)
|
||||
|
||||
|
||||
def _replace_file(source: Path, target: Path) -> None:
|
||||
_private_dir(target.parent)
|
||||
temporary = target.with_name(target.name + ".restore-" + uuid.uuid4().hex)
|
||||
try:
|
||||
shutil.copyfile(source, temporary)
|
||||
temporary.chmod(0o600)
|
||||
with temporary.open("r+b") as handle:
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, target)
|
||||
_sync_directory(target.parent)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _replace_assets(source: Path, target: Path) -> None:
|
||||
if target.is_symlink():
|
||||
raise BackupError("Asset directories must not be symbolic links")
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
if source.exists():
|
||||
shutil.copytree(source, target, copy_function=shutil.copyfile)
|
||||
for parent, _directories, files in os.walk(target):
|
||||
Path(parent).chmod(0o700)
|
||||
for filename in files:
|
||||
(Path(parent) / filename).chmod(0o600)
|
||||
_sync_tree(target)
|
||||
if target.parent.exists():
|
||||
_sync_directory(target.parent)
|
||||
|
||||
|
||||
def _recover(journal: dict, root: Path) -> None:
|
||||
rollback_name = journal.get("rollback_directory", "")
|
||||
if not re.fullmatch(r"rollback-[0-9a-f]{32}", rollback_name):
|
||||
raise BackupError("Restore recovery journal is invalid")
|
||||
rollback = root / rollback_name
|
||||
database = Path(_db_path()).absolute()
|
||||
if journal["had_database"]:
|
||||
_replace_file(rollback / "database.sqlite3", database)
|
||||
else:
|
||||
database.unlink(missing_ok=True)
|
||||
for suffix in ("-wal", "-shm", "-journal"):
|
||||
Path(str(database) + suffix).unlink(missing_ok=True)
|
||||
for name in journal["asset_roots"]:
|
||||
if name not in {"branding", "artwork"}:
|
||||
raise BackupError("Restore recovery journal is invalid")
|
||||
_replace_assets(rollback / "files" / name, _assets_root() / name)
|
||||
_write_json(root / "last-restore.json", {
|
||||
"status": "rolled_back", "restored_at": _now(), "rollback_directory": rollback.name,
|
||||
"message": "An interrupted or failed restore was rolled back automatically.",
|
||||
})
|
||||
_write_json(root / "restore-journal.json", {**journal, "phase": "rolled_back"})
|
||||
pending = root / "pending"
|
||||
if pending.exists():
|
||||
shutil.rmtree(pending)
|
||||
(root / "restore-journal.json").unlink()
|
||||
_sync_directory(root)
|
||||
|
||||
|
||||
def apply_pending_restore() -> bool:
|
||||
"""Call once before init_db, with no other backend processes using the DB."""
|
||||
with _exclusive_operation():
|
||||
root = _control_root()
|
||||
journal_path = root / "restore-journal.json"
|
||||
if journal_path.exists():
|
||||
journal = json.loads(journal_path.read_text())
|
||||
if journal.get("phase") in {"complete", "rolled_back"}:
|
||||
if (root / "pending").exists():
|
||||
shutil.rmtree(root / "pending")
|
||||
journal_path.unlink()
|
||||
_sync_directory(root)
|
||||
return journal["phase"] == "complete"
|
||||
_recover(journal, root)
|
||||
return False
|
||||
pending = root / "pending"
|
||||
if not pending.exists():
|
||||
return False
|
||||
if pending.is_symlink():
|
||||
raise BackupError("Invalid staged restore directory")
|
||||
metadata = json.loads((pending / "metadata.json").read_text())
|
||||
_validate_database(pending / "database.sqlite3", verify_settings_encryption=True)
|
||||
database = Path(_db_path()).absolute()
|
||||
rollback = root / ("rollback-" + uuid.uuid4().hex)
|
||||
_private_dir(rollback)
|
||||
# Ensure all disk-space/permission failures in backup happen before replacement.
|
||||
if database.exists():
|
||||
_database_copy(database, rollback / "database.sqlite3")
|
||||
names = ["branding", "artwork"] if metadata["include_cache"] else ["branding"]
|
||||
# Reject links anywhere before copying or deleting the controlled asset trees.
|
||||
list(_asset_files(metadata["include_cache"]))
|
||||
for name in names:
|
||||
source = _assets_root() / name
|
||||
if source.exists():
|
||||
shutil.copytree(source, rollback / "files" / name)
|
||||
_sync_tree(rollback)
|
||||
journal = {"rollback_directory": rollback.name, "had_database": database.exists(), "asset_roots": names}
|
||||
_write_json(journal_path, journal)
|
||||
try:
|
||||
for suffix in ("-wal", "-shm", "-journal"):
|
||||
Path(str(database) + suffix).unlink(missing_ok=True)
|
||||
_replace_file(pending / "database.sqlite3", database)
|
||||
for name in names:
|
||||
_replace_assets(pending / "files" / name, _assets_root() / name)
|
||||
_write_json(root / "last-restore.json", {
|
||||
"status": "restored", "restored_at": _now(), "rollback_directory": rollback.name,
|
||||
"backup_created_at": metadata["created_at"],
|
||||
})
|
||||
_write_json(journal_path, {**journal, "phase": "complete"})
|
||||
except Exception:
|
||||
_recover(journal, root)
|
||||
raise
|
||||
shutil.rmtree(pending)
|
||||
journal_path.unlink()
|
||||
_sync_directory(root)
|
||||
return True
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Read title-specific search activity without starting a search or changing monitoring."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..clients.base import ApiClient
|
||||
from ..models import RequestType
|
||||
|
||||
|
||||
def _ids(values: Any) -> set[int]:
|
||||
if not isinstance(values, list):
|
||||
return set()
|
||||
return {value for value in values if type(value) is int and value > 0}
|
||||
|
||||
|
||||
def search_status(commands: Any, request_type: RequestType, item_id: int, episodes: Any = None) -> str:
|
||||
"""Only a matching queued/started search is evidence of current activity.
|
||||
|
||||
Completed commands, RSS syncs and library-wide jobs do not establish that this
|
||||
title is being searched. Episode searches are matched using Sonarr episode IDs.
|
||||
"""
|
||||
if not isinstance(commands, list):
|
||||
return "unavailable"
|
||||
episode_ids = _ids([
|
||||
episode.get("id") for episode in (episodes if isinstance(episodes, list) else [])
|
||||
if isinstance(episode, dict) and episode.get("seriesId", item_id) == item_id
|
||||
])
|
||||
queued = False
|
||||
for command in commands:
|
||||
if not isinstance(command, dict):
|
||||
continue
|
||||
body = command.get("body")
|
||||
if not isinstance(body, dict):
|
||||
continue
|
||||
name = str(command.get("name") or body.get("name") or "").lower()
|
||||
if request_type == RequestType.movie:
|
||||
matches = name == "moviessearch" and item_id in _ids(body.get("movieIds"))
|
||||
else:
|
||||
matches = (
|
||||
name in {"seriessearch", "seasonsearch"} and body.get("seriesId") == item_id
|
||||
) or (
|
||||
name == "episodesearch" and bool(episode_ids & _ids(body.get("episodeIds")))
|
||||
)
|
||||
if not matches or command.get("ended"):
|
||||
continue
|
||||
status = str(command.get("status", "")).lower()
|
||||
if status in {"started", "1"}:
|
||||
return "searching"
|
||||
if status in {"queued", "0"}:
|
||||
queued = True
|
||||
return "queued" if queued else "idle"
|
||||
|
||||
|
||||
async def read_search_status(
|
||||
client: ApiClient, request_type: RequestType, item_id: int, episodes: Any = None,
|
||||
) -> str:
|
||||
try:
|
||||
commands = await client.get("/api/v3/command", timeout_seconds=3.0)
|
||||
except Exception:
|
||||
# Search telemetry must not turn a healthy library record into an error.
|
||||
return "unavailable"
|
||||
return search_status(commands, request_type, item_id, episodes)
|
||||
@@ -0,0 +1,735 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from time import perf_counter
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..clients.prowlarr import ProwlarrClient
|
||||
from ..clients.qbittorrent import QBittorrentClient
|
||||
from ..clients.radarr import RadarrClient
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..config import settings as env_settings
|
||||
from ..db import get_database_diagnostics
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..runtime import get_runtime_settings
|
||||
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
|
||||
|
||||
|
||||
DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiagnosticCheck:
|
||||
key: str
|
||||
label: str
|
||||
category: str
|
||||
description: str
|
||||
live_safe: bool
|
||||
configured: bool
|
||||
config_detail: str
|
||||
target: Optional[str]
|
||||
runner: DiagnosticRunner
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _clean_text(value: Any, fallback: str = "") -> str:
|
||||
if value is None:
|
||||
return fallback
|
||||
if isinstance(value, str):
|
||||
trimmed = value.strip()
|
||||
return trimmed if trimmed else fallback
|
||||
return str(value)
|
||||
|
||||
|
||||
def _url_target(url: Optional[str]) -> Optional[str]:
|
||||
raw = _clean_text(url)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = urlparse(raw)
|
||||
except Exception:
|
||||
return raw
|
||||
host = parsed.hostname or parsed.netloc or raw
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
return host
|
||||
|
||||
|
||||
def _host_port_target(host: Optional[str], port: Optional[int]) -> Optional[str]:
|
||||
resolved_host = _clean_text(host)
|
||||
if not resolved_host:
|
||||
return None
|
||||
if port is None:
|
||||
return resolved_host
|
||||
return f"{resolved_host}:{port}"
|
||||
|
||||
|
||||
def _http_error_detail(exc: Exception) -> str:
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
response = exc.response
|
||||
body = ""
|
||||
try:
|
||||
body = response.text.strip()
|
||||
except Exception:
|
||||
body = ""
|
||||
if body:
|
||||
return f"HTTP {response.status_code}: {body}"
|
||||
return f"HTTP {response.status_code}"
|
||||
return str(exc)
|
||||
|
||||
|
||||
def _config_status(detail: str) -> str:
|
||||
lowered = detail.lower()
|
||||
if "disabled" in lowered:
|
||||
return "disabled"
|
||||
return "not_configured"
|
||||
|
||||
|
||||
def _discord_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
|
||||
return False, "Discord notifications are disabled."
|
||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
||||
if webhook_url:
|
||||
try:
|
||||
validate_notification_target_url(webhook_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Discord webhook URL is required."
|
||||
|
||||
|
||||
def _telegram_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_telegram_enabled:
|
||||
return False, "Telegram notifications are disabled."
|
||||
if _clean_text(runtime.magent_notify_telegram_bot_token) and _clean_text(runtime.magent_notify_telegram_chat_id):
|
||||
return True, "ok"
|
||||
return False, "Telegram bot token and chat ID are required."
|
||||
|
||||
|
||||
def _webhook_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
|
||||
return False, "Generic webhook notifications are disabled."
|
||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
||||
if webhook_url:
|
||||
try:
|
||||
validate_notification_target_url(webhook_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Generic webhook URL is required."
|
||||
|
||||
|
||||
def _push_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_push_enabled:
|
||||
return False, "Push notifications are disabled."
|
||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||
if provider == "ntfy":
|
||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
if push_url and _clean_text(runtime.magent_notify_push_topic):
|
||||
try:
|
||||
validate_notification_target_url(push_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "ntfy requires a base URL and topic."
|
||||
if provider == "gotify":
|
||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
if push_url and _clean_text(runtime.magent_notify_push_token):
|
||||
try:
|
||||
validate_notification_target_url(push_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Gotify requires a base URL and app token."
|
||||
if provider == "pushover":
|
||||
if _clean_text(runtime.magent_notify_push_token) and _clean_text(runtime.magent_notify_push_user_key):
|
||||
return True, "ok"
|
||||
return False, "Pushover requires an application token and user key."
|
||||
if provider == "webhook":
|
||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
if push_url:
|
||||
try:
|
||||
validate_notification_target_url(push_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Webhook relay requires a target URL."
|
||||
if provider == "telegram":
|
||||
return _telegram_config_ready(runtime)
|
||||
if provider == "discord":
|
||||
return _discord_config_ready(runtime)
|
||||
return False, f"Unsupported push provider: {provider or 'unknown'}"
|
||||
|
||||
|
||||
def _summary_from_results(results: Sequence[Dict[str, Any]]) -> Dict[str, int]:
|
||||
summary = {
|
||||
"total": len(results),
|
||||
"up": 0,
|
||||
"down": 0,
|
||||
"degraded": 0,
|
||||
"not_configured": 0,
|
||||
"disabled": 0,
|
||||
}
|
||||
for result in results:
|
||||
status = str(result.get("status") or "").strip().lower()
|
||||
if status in summary:
|
||||
summary[status] += 1
|
||||
return summary
|
||||
|
||||
|
||||
async def _run_http_json_get(
|
||||
url: str,
|
||||
*,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {"response": payload}
|
||||
|
||||
|
||||
async def _run_http_text_get(url: str) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
body = response.text
|
||||
return {"response": body, "message": f"HTTP {response.status_code}"}
|
||||
|
||||
|
||||
async def _run_http_post(
|
||||
url: str,
|
||||
*,
|
||||
json_payload: Optional[Dict[str, Any]] = None,
|
||||
data_payload: Any = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
validate_notification_target_url(url)
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
if not response.content:
|
||||
return {"message": f"HTTP {response.status_code}"}
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" in content_type.lower():
|
||||
try:
|
||||
return {"response": response.json(), "message": f"HTTP {response.status_code}"}
|
||||
except Exception:
|
||||
pass
|
||||
return {"response": response.text.strip(), "message": f"HTTP {response.status_code}"}
|
||||
|
||||
|
||||
async def _run_database_check() -> Dict[str, Any]:
|
||||
detail = await asyncio.to_thread(get_database_diagnostics)
|
||||
integrity = _clean_text(detail.get("integrity_check"), "unknown")
|
||||
requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
|
||||
wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
|
||||
wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
|
||||
status = "up" if integrity == "ok" else "degraded"
|
||||
return {
|
||||
"status": status,
|
||||
"message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
|
||||
async def _run_magent_api_check(runtime) -> Dict[str, Any]:
|
||||
base_url = _clean_text(runtime.magent_api_url) or f"http://127.0.0.1:{int(runtime.magent_api_port or 8000)}"
|
||||
result = await _run_http_json_get(f"{base_url.rstrip('/')}/health")
|
||||
payload = result.get("response")
|
||||
build_number = payload.get("build") if isinstance(payload, dict) else None
|
||||
message = "Health endpoint responded"
|
||||
if build_number:
|
||||
message = f"Health endpoint responded (build {build_number})"
|
||||
return {"message": message, "detail": payload}
|
||||
|
||||
|
||||
async def _run_magent_web_check(runtime) -> Dict[str, Any]:
|
||||
base_url = _clean_text(runtime.magent_application_url) or f"http://127.0.0.1:{int(runtime.magent_application_port or 3000)}"
|
||||
result = await _run_http_text_get(base_url.rstrip("/"))
|
||||
body = result.get("response")
|
||||
if isinstance(body, str) and "<html" in body.lower():
|
||||
return {"message": "Application page responded", "detail": "html"}
|
||||
return {"status": "degraded", "message": "Application responded with unexpected content"}
|
||||
|
||||
|
||||
async def _run_seerr_check(runtime) -> Dict[str, Any]:
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
payload = await client.get_status()
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
message = "Seerr responded"
|
||||
if version:
|
||||
message = f"Seerr version {version}"
|
||||
return {"message": message, "detail": payload}
|
||||
|
||||
|
||||
async def _run_sonarr_check(runtime) -> Dict[str, Any]:
|
||||
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
payload = await client.get_system_status()
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
message = "Sonarr responded"
|
||||
if version:
|
||||
message = f"Sonarr version {version}"
|
||||
return {"message": message, "detail": payload}
|
||||
|
||||
|
||||
async def _run_radarr_check(runtime) -> Dict[str, Any]:
|
||||
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
payload = await client.get_system_status()
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
message = "Radarr responded"
|
||||
if version:
|
||||
message = f"Radarr version {version}"
|
||||
return {"message": message, "detail": payload}
|
||||
|
||||
|
||||
async def _run_prowlarr_check(runtime) -> Dict[str, Any]:
|
||||
client = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
payload = await client.get_health()
|
||||
if isinstance(payload, list) and payload:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"message": f"Prowlarr health warnings: {len(payload)}",
|
||||
"detail": payload,
|
||||
}
|
||||
return {"message": "Prowlarr reported healthy", "detail": payload}
|
||||
|
||||
|
||||
async def _run_qbittorrent_check(runtime) -> Dict[str, Any]:
|
||||
client = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url,
|
||||
runtime.qbittorrent_username,
|
||||
runtime.qbittorrent_password,
|
||||
)
|
||||
version = await client.get_app_version()
|
||||
message = "qBittorrent responded"
|
||||
if isinstance(version, str) and version:
|
||||
message = f"qBittorrent version {version}"
|
||||
return {"message": message, "detail": version}
|
||||
|
||||
|
||||
async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
payload = await client.get_system_info()
|
||||
version = payload.get("Version") if isinstance(payload, dict) else None
|
||||
message = "Jellyfin responded"
|
||||
if version:
|
||||
message = f"Jellyfin version {version}"
|
||||
return {"message": message, "detail": payload}
|
||||
|
||||
|
||||
async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
||||
result = await send_test_email(recipient_email=recipient_email)
|
||||
recipient = _clean_text(result.get("recipient_email"), "configured recipient")
|
||||
warning = _clean_text(result.get("warning"))
|
||||
if warning:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
|
||||
"detail": result,
|
||||
}
|
||||
return {"message": f"Test email sent to {recipient}", "detail": result}
|
||||
|
||||
|
||||
async def _run_discord_check(runtime) -> Dict[str, Any]:
|
||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
||||
payload = {
|
||||
"content": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
|
||||
}
|
||||
result = await _run_http_post(webhook_url, json_payload=payload)
|
||||
return {"message": "Discord webhook accepted ping", "detail": result.get("response")}
|
||||
|
||||
|
||||
async def _run_telegram_check(runtime) -> Dict[str, Any]:
|
||||
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
||||
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
"text": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
|
||||
}
|
||||
result = await _run_http_post(url, json_payload=payload)
|
||||
return {"message": "Telegram ping accepted", "detail": result.get("response")}
|
||||
|
||||
|
||||
async def _run_webhook_check(runtime) -> Dict[str, Any]:
|
||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
||||
payload = {
|
||||
"type": "diagnostics.ping",
|
||||
"application": env_settings.app_name,
|
||||
"build": env_settings.site_build_number,
|
||||
"checked_at": _now_iso(),
|
||||
}
|
||||
result = await _run_http_post(webhook_url, json_payload=payload)
|
||||
return {"message": "Webhook accepted ping", "detail": result.get("response")}
|
||||
|
||||
|
||||
async def _run_push_check(runtime) -> Dict[str, Any]:
|
||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||
message = f"{env_settings.app_name} diagnostics ping"
|
||||
build_suffix = f"Build {env_settings.site_build_number or 'unknown'}"
|
||||
|
||||
if provider == "ntfy":
|
||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
topic = _clean_text(runtime.magent_notify_push_topic)
|
||||
result = await _run_http_post(
|
||||
f"{base_url.rstrip('/')}/{topic}",
|
||||
data_payload=f"{message}\n{build_suffix}",
|
||||
headers={"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
return {"message": "ntfy push accepted", "detail": result.get("response")}
|
||||
|
||||
if provider == "gotify":
|
||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
token = _clean_text(runtime.magent_notify_push_token)
|
||||
result = await _run_http_post(
|
||||
f"{base_url.rstrip('/')}/message",
|
||||
json_payload={"title": env_settings.app_name, "message": build_suffix, "priority": 5},
|
||||
params={"token": token},
|
||||
)
|
||||
return {"message": "Gotify push accepted", "detail": result.get("response")}
|
||||
|
||||
if provider == "pushover":
|
||||
token = _clean_text(runtime.magent_notify_push_token)
|
||||
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
||||
device = _clean_text(runtime.magent_notify_push_device)
|
||||
payload = {
|
||||
"token": token,
|
||||
"user": user_key,
|
||||
"message": f"{message}\n{build_suffix}",
|
||||
"title": env_settings.app_name,
|
||||
}
|
||||
if device:
|
||||
payload["device"] = device
|
||||
result = await _run_http_post("https://api.pushover.net/1/messages.json", data_payload=payload)
|
||||
return {"message": "Pushover push accepted", "detail": result.get("response")}
|
||||
|
||||
if provider == "webhook":
|
||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
payload = {
|
||||
"type": "diagnostics.push",
|
||||
"application": env_settings.app_name,
|
||||
"build": env_settings.site_build_number,
|
||||
"checked_at": _now_iso(),
|
||||
}
|
||||
result = await _run_http_post(base_url, json_payload=payload)
|
||||
return {"message": "Push webhook accepted", "detail": result.get("response")}
|
||||
|
||||
if provider == "telegram":
|
||||
return await _run_telegram_check(runtime)
|
||||
|
||||
if provider == "discord":
|
||||
return await _run_discord_check(runtime)
|
||||
|
||||
raise RuntimeError(f"Unsupported push provider: {provider}")
|
||||
|
||||
|
||||
def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[DiagnosticCheck]:
|
||||
runtime = get_runtime_settings()
|
||||
seerr_target = _url_target(runtime.jellyseerr_base_url)
|
||||
jellyfin_target = _url_target(runtime.jellyfin_base_url)
|
||||
sonarr_target = _url_target(runtime.sonarr_base_url)
|
||||
radarr_target = _url_target(runtime.radarr_base_url)
|
||||
prowlarr_target = _url_target(runtime.prowlarr_base_url)
|
||||
qbittorrent_target = _url_target(runtime.qbittorrent_base_url)
|
||||
application_target = _url_target(runtime.magent_application_url) or _host_port_target("127.0.0.1", runtime.magent_application_port)
|
||||
api_target = _url_target(runtime.magent_api_url) or _host_port_target("127.0.0.1", runtime.magent_api_port)
|
||||
smtp_target = _host_port_target(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port)
|
||||
discord_target = _url_target(runtime.magent_notify_discord_webhook_url) or _url_target(runtime.discord_webhook_url)
|
||||
telegram_target = "api.telegram.org" if _clean_text(runtime.magent_notify_telegram_bot_token) else None
|
||||
webhook_target = _url_target(runtime.magent_notify_webhook_url)
|
||||
|
||||
push_provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||
push_target = None
|
||||
if push_provider == "pushover":
|
||||
push_target = "api.pushover.net"
|
||||
elif push_provider == "telegram":
|
||||
push_target = telegram_target or "api.telegram.org"
|
||||
elif push_provider == "discord":
|
||||
push_target = discord_target or "discord.com"
|
||||
else:
|
||||
push_target = _url_target(runtime.magent_notify_push_base_url)
|
||||
|
||||
email_ready, email_detail = smtp_email_config_ready()
|
||||
email_warning = smtp_email_delivery_warning()
|
||||
discord_ready, discord_detail = _discord_config_ready(runtime)
|
||||
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
|
||||
push_ready, push_detail = _push_config_ready(runtime)
|
||||
webhook_ready, webhook_detail = _webhook_config_ready(runtime)
|
||||
|
||||
checks = [
|
||||
DiagnosticCheck(
|
||||
key="magent-web",
|
||||
label="Magent application",
|
||||
category="Application",
|
||||
description="Checks that the frontend application URL is responding.",
|
||||
live_safe=True,
|
||||
configured=True,
|
||||
config_detail="ok",
|
||||
target=application_target,
|
||||
runner=lambda runtime=runtime: _run_magent_web_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="magent-api",
|
||||
label="Magent API",
|
||||
category="Application",
|
||||
description="Checks the Magent API health endpoint.",
|
||||
live_safe=True,
|
||||
configured=True,
|
||||
config_detail="ok",
|
||||
target=api_target,
|
||||
runner=lambda runtime=runtime: _run_magent_api_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="database",
|
||||
label="SQLite database",
|
||||
category="Application",
|
||||
description="Runs SQLite integrity_check against the current Magent database.",
|
||||
live_safe=True,
|
||||
configured=True,
|
||||
config_detail="ok",
|
||||
target="sqlite",
|
||||
runner=_run_database_check,
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="seerr",
|
||||
label="Seerr",
|
||||
category="Media services",
|
||||
description="Checks Seerr API reachability and version.",
|
||||
live_safe=True,
|
||||
configured=bool(runtime.jellyseerr_base_url and runtime.jellyseerr_api_key),
|
||||
config_detail="Seerr URL and API key are required.",
|
||||
target=seerr_target,
|
||||
runner=lambda runtime=runtime: _run_seerr_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="jellyfin",
|
||||
label="Jellyfin",
|
||||
category="Media services",
|
||||
description="Checks Jellyfin system info with the configured API key.",
|
||||
live_safe=True,
|
||||
configured=bool(runtime.jellyfin_base_url and runtime.jellyfin_api_key),
|
||||
config_detail="Jellyfin URL and API key are required.",
|
||||
target=jellyfin_target,
|
||||
runner=lambda runtime=runtime: _run_jellyfin_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="sonarr",
|
||||
label="Sonarr",
|
||||
category="Media services",
|
||||
description="Checks Sonarr system status with the configured API key.",
|
||||
live_safe=True,
|
||||
configured=bool(runtime.sonarr_base_url and runtime.sonarr_api_key),
|
||||
config_detail="Sonarr URL and API key are required.",
|
||||
target=sonarr_target,
|
||||
runner=lambda runtime=runtime: _run_sonarr_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="radarr",
|
||||
label="Radarr",
|
||||
category="Media services",
|
||||
description="Checks Radarr system status with the configured API key.",
|
||||
live_safe=True,
|
||||
configured=bool(runtime.radarr_base_url and runtime.radarr_api_key),
|
||||
config_detail="Radarr URL and API key are required.",
|
||||
target=radarr_target,
|
||||
runner=lambda runtime=runtime: _run_radarr_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="prowlarr",
|
||||
label="Prowlarr",
|
||||
category="Media services",
|
||||
description="Checks Prowlarr health and flags warnings as degraded.",
|
||||
live_safe=True,
|
||||
configured=bool(runtime.prowlarr_base_url and runtime.prowlarr_api_key),
|
||||
config_detail="Prowlarr URL and API key are required.",
|
||||
target=prowlarr_target,
|
||||
runner=lambda runtime=runtime: _run_prowlarr_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="qbittorrent",
|
||||
label="qBittorrent",
|
||||
category="Media services",
|
||||
description="Checks qBittorrent login and app version.",
|
||||
live_safe=True,
|
||||
configured=bool(
|
||||
runtime.qbittorrent_base_url and runtime.qbittorrent_username and runtime.qbittorrent_password
|
||||
),
|
||||
config_detail="qBittorrent URL, username, and password are required.",
|
||||
target=qbittorrent_target,
|
||||
runner=lambda runtime=runtime: _run_qbittorrent_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="email",
|
||||
label="SMTP email",
|
||||
category="Notifications",
|
||||
description="Sends a live test email using the configured SMTP provider.",
|
||||
live_safe=False,
|
||||
configured=email_ready,
|
||||
config_detail=email_warning or email_detail,
|
||||
target=smtp_target,
|
||||
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="discord",
|
||||
label="Discord webhook",
|
||||
category="Notifications",
|
||||
description="Posts a live test message to the configured Discord webhook.",
|
||||
live_safe=False,
|
||||
configured=discord_ready,
|
||||
config_detail=discord_detail,
|
||||
target=discord_target,
|
||||
runner=lambda runtime=runtime: _run_discord_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="telegram",
|
||||
label="Telegram",
|
||||
category="Notifications",
|
||||
description="Sends a live test message to the configured Telegram chat.",
|
||||
live_safe=False,
|
||||
configured=telegram_ready,
|
||||
config_detail=telegram_detail,
|
||||
target=telegram_target,
|
||||
runner=lambda runtime=runtime: _run_telegram_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="push",
|
||||
label="Push/mobile provider",
|
||||
category="Notifications",
|
||||
description="Sends a live test message through the configured push provider.",
|
||||
live_safe=False,
|
||||
configured=push_ready,
|
||||
config_detail=push_detail,
|
||||
target=push_target,
|
||||
runner=lambda runtime=runtime: _run_push_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="webhook",
|
||||
label="Generic webhook",
|
||||
category="Notifications",
|
||||
description="Posts a live test payload to the configured generic webhook.",
|
||||
live_safe=False,
|
||||
configured=webhook_ready,
|
||||
config_detail=webhook_detail,
|
||||
target=webhook_target,
|
||||
runner=lambda runtime=runtime: _run_webhook_check(runtime),
|
||||
),
|
||||
]
|
||||
return checks
|
||||
|
||||
|
||||
async def _execute_check(check: DiagnosticCheck) -> Dict[str, Any]:
|
||||
if not check.configured:
|
||||
return {
|
||||
"key": check.key,
|
||||
"label": check.label,
|
||||
"category": check.category,
|
||||
"description": check.description,
|
||||
"target": check.target,
|
||||
"live_safe": check.live_safe,
|
||||
"configured": False,
|
||||
"status": _config_status(check.config_detail),
|
||||
"message": check.config_detail,
|
||||
"checked_at": _now_iso(),
|
||||
"duration_ms": 0,
|
||||
}
|
||||
|
||||
started = perf_counter()
|
||||
checked_at = _now_iso()
|
||||
try:
|
||||
payload = await check.runner()
|
||||
status = _clean_text(payload.get("status"), "up")
|
||||
message = _clean_text(payload.get("message"), "Check passed")
|
||||
detail = payload.get("detail")
|
||||
return {
|
||||
"key": check.key,
|
||||
"label": check.label,
|
||||
"category": check.category,
|
||||
"description": check.description,
|
||||
"target": check.target,
|
||||
"live_safe": check.live_safe,
|
||||
"configured": True,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"detail": detail,
|
||||
"checked_at": checked_at,
|
||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
||||
}
|
||||
except httpx.HTTPError as exc:
|
||||
return {
|
||||
"key": check.key,
|
||||
"label": check.label,
|
||||
"category": check.category,
|
||||
"description": check.description,
|
||||
"target": check.target,
|
||||
"live_safe": check.live_safe,
|
||||
"configured": True,
|
||||
"status": "down",
|
||||
"message": _http_error_detail(exc),
|
||||
"checked_at": checked_at,
|
||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"key": check.key,
|
||||
"label": check.label,
|
||||
"category": check.category,
|
||||
"description": check.description,
|
||||
"target": check.target,
|
||||
"live_safe": check.live_safe,
|
||||
"configured": True,
|
||||
"status": "down",
|
||||
"message": str(exc),
|
||||
"checked_at": checked_at,
|
||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
||||
}
|
||||
|
||||
|
||||
def get_diagnostics_catalog() -> Dict[str, Any]:
|
||||
checks = _build_diagnostic_checks()
|
||||
items = []
|
||||
for check in checks:
|
||||
items.append(
|
||||
{
|
||||
"key": check.key,
|
||||
"label": check.label,
|
||||
"category": check.category,
|
||||
"description": check.description,
|
||||
"live_safe": check.live_safe,
|
||||
"target": check.target,
|
||||
"configured": check.configured,
|
||||
"config_status": "configured" if check.configured else _config_status(check.config_detail),
|
||||
"config_detail": "Ready to test." if check.configured else check.config_detail,
|
||||
}
|
||||
)
|
||||
categories = sorted({item["category"] for item in items})
|
||||
return {
|
||||
"checks": items,
|
||||
"categories": categories,
|
||||
"generated_at": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def run_diagnostics(keys: Optional[Sequence[str]] = None, recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
||||
checks = _build_diagnostic_checks(recipient_email=recipient_email)
|
||||
selected = {str(key).strip().lower() for key in (keys or []) if str(key).strip()}
|
||||
if selected:
|
||||
checks = [check for check in checks if check.key.lower() in selected]
|
||||
results = await asyncio.gather(*(_execute_check(check) for check in checks))
|
||||
return {
|
||||
"results": results,
|
||||
"summary": _summary_from_results(results),
|
||||
"checked_at": _now_iso(),
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
from typing import Any
|
||||
|
||||
|
||||
def label_episode_downloads(torrents: list[dict], queue: Any) -> list[dict]:
|
||||
"""Join by collector download ID, never by fuzzy title matching.
|
||||
|
||||
A pack shares one transfer percentage; do not pretend its episodes have
|
||||
individually measured progress.
|
||||
"""
|
||||
records = queue.get("records", []) if isinstance(queue, dict) else queue
|
||||
labels: dict[str, set[str]] = {}
|
||||
for row in records if isinstance(records, list) else []:
|
||||
episode = row.get("episode") or {}
|
||||
season, number = episode.get("seasonNumber"), episode.get("episodeNumber")
|
||||
if isinstance(season, int) and isinstance(number, int):
|
||||
key = str(row.get("downloadId") or "").lower()
|
||||
labels.setdefault(key, set()).add(f"S{season:02d}E{number:02d}")
|
||||
for torrent in torrents:
|
||||
episodes = sorted(labels.get(str(torrent.get("hash") or "").lower(), set()))
|
||||
torrent["episodeLabels"] = episodes
|
||||
torrent["episodeLabel"] = (
|
||||
" · ".join(episodes) + (" — shared download progress" if len(episodes) > 1 else "")
|
||||
if episodes else None
|
||||
)
|
||||
return torrents
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Reviewed consolidation of accounts sharing a verified Jellyfin ID, entirely within Magent."""
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from .. import db
|
||||
from ..feature_access import FEATURES
|
||||
from . import identity_review as review
|
||||
from .jellyfin_identity import source_key
|
||||
|
||||
NAME_REFERENCES = {
|
||||
'signup_invites': ('created_by',),
|
||||
'portal_items': ('created_by_username', 'assignee_username'),
|
||||
'portal_comments': ('author_username',),
|
||||
'portal_item_activity': ('actor_username',),
|
||||
'platform_issues': ('reporter_username',),
|
||||
'platform_issue_events': ('author_username',),
|
||||
'requests_cache': ('requested_by', 'requested_by_norm'),
|
||||
}
|
||||
|
||||
|
||||
def account_state(conn, ids):
|
||||
conn.row_factory = db.sqlite3.Row
|
||||
placeholders = ','.join('?' for _ in ids)
|
||||
return {table: [dict(row) for row in conn.execute(
|
||||
f'SELECT * FROM {table} WHERE {column} IN ({placeholders}) ORDER BY {column}', ids)]
|
||||
for table, column in [('users', 'id'), ('user_feature_permissions', 'user_id'),
|
||||
('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 = identity_group(report, target)
|
||||
ids = {row['user']['id'] for row in group}
|
||||
if len(ids) < 2:
|
||||
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}
|
||||
recommended = min(ids, key=lambda identity: (identity not in owned, identity))
|
||||
keep_id = keep_id or recommended
|
||||
if keep_id not in ids:
|
||||
raise HTTPException(400, 'Choose an account from this duplicate group to keep.')
|
||||
problems = []
|
||||
if any(report['services'].get(service) != 'available' for service in ('jellyfin', 'seerr', 'jellystat')):
|
||||
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.')
|
||||
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
|
||||
for row in group:
|
||||
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']:
|
||||
if link['local_user_id'] in ids:
|
||||
if link['source'] != source or review.normalized_id(link['jellyfin_user_id']) != jf_id:
|
||||
problems.append('A duplicate has a different saved Jellyfin identity or server.')
|
||||
elif link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id:
|
||||
problems.append('Another account or orphaned reservation owns this Jellyfin identity.')
|
||||
for item in local['confirmations']:
|
||||
if item['local_user_id'] in ids:
|
||||
if (item['jellyfin_server_id'] != report['server_id'] or item['jellyfin_user_id'] != jf_id
|
||||
or item['jellyfin_source'] != source or item['seerr_source'] != source_key(runtime.jellyseerr_base_url)
|
||||
or item['seerr_user_id'] != seerr_id):
|
||||
problems.append('A saved confirmation points to a different identity or server.')
|
||||
elif item['jellyfin_server_id'] == report['server_id'] and item['jellyfin_user_id'] == jf_id:
|
||||
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 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), 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
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
expiry = kept['expires_at']
|
||||
problems.append('An expiry date is invalid. Correct it before repairing duplicates.')
|
||||
proposed = {'id': keep_id, 'username': target['jellyfin']['name'] if target['jellyfin'] else kept['username'],
|
||||
'email': kept['email'], 'profile_id': kept['profile_id'], 'expires_at': expiry,
|
||||
'is_blocked': any(account['is_blocked'] for account in accounts),
|
||||
'auto_search_enabled': all(account['auto_search_enabled'] for account in accounts),
|
||||
'features': features, 'jellyfin_user_id': jf_id, 'seerr_user_id': seerr_id}
|
||||
public = [{key: account.get(key) for key in ('id', 'username', 'email', 'profile_id', 'last_login_at', 'created_at')}
|
||||
for account in accounts]
|
||||
return {'accounts': public, 'keep_id': keep_id, 'recommended_id': recommended, 'proposed': proposed,
|
||||
'issues': sorted(set(problems)), 'can_confirm': not problems,
|
||||
'revision': review.digest([report['revision'], state, keep_id, proposed])}
|
||||
|
||||
|
||||
async def prepare(user_id, keep_id=None):
|
||||
report, local, runtime = await review.review_identities()
|
||||
target = next((row for row in local['users'] if row['id'] == user_id), None)
|
||||
if not target:
|
||||
raise HTTPException(404, 'Account not found.')
|
||||
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):
|
||||
raise HTTPException(409, 'Accounts changed during the check. Preview again.')
|
||||
state = account_state(conn, ids)
|
||||
return build_preview(report, local, runtime, state, user_id, keep_id), report, local, runtime, state
|
||||
|
||||
|
||||
def consolidate(preview, report, local, runtime, state, admin):
|
||||
if not preview['can_confirm']:
|
||||
raise HTTPException(409, 'This duplicate group cannot be consolidated. Review the listed conflicts.')
|
||||
ids = sorted(account['id'] for account in state['users'])
|
||||
keep = preview['keep_id']
|
||||
removed = [identity for identity in ids if identity != keep]
|
||||
values = preview['proposed']
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
if (review.digest(review.snapshot(conn)) != review.digest(local)
|
||||
or review.digest(account_state(conn, ids)) != review.digest(state)
|
||||
or review.config_digest(review.get_runtime_settings()) != review.config_digest(runtime)):
|
||||
raise HTTPException(409, 'Accounts, permissions or subscriptions changed. Preview again before saving.')
|
||||
for table in ('email_recap_deliveries', 'newsletter_deliveries'):
|
||||
if conn.execute(f"SELECT 1 FROM {table} WHERE user_id IN ({','.join('?' for _ in ids)}) AND state='sending'", ids).fetchone():
|
||||
raise HTTPException(409, 'An account email is currently being sent. Wait for delivery to finish, then preview again.')
|
||||
archive = {**state, 'links': [entry for entry in local['links'] if entry['local_user_id'] in ids],
|
||||
'confirmations': [entry for entry in local['confirmations'] if entry['local_user_id'] in ids],
|
||||
'proposed': values}
|
||||
conn.execute('INSERT INTO user_duplicate_repairs(kept_user_id,archive_json,repaired_by,repaired_at) VALUES(?,?,?,?)',
|
||||
(keep, json.dumps(archive, sort_keys=True), admin['username'], now))
|
||||
names = {account['username'] for account in state['users']}
|
||||
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
for table, columns in NAME_REFERENCES.items():
|
||||
if table not in tables:
|
||||
continue
|
||||
for column in columns:
|
||||
old_values = {review.name_key(name) for name in names} if column == 'requested_by_norm' else names
|
||||
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 row['username'] in names]
|
||||
for entry in activity:
|
||||
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
|
||||
for entry in activity:
|
||||
conn.execute('''INSERT INTO user_activity(username,ip,user_agent,first_seen_at,last_seen_at,hit_count)
|
||||
VALUES(?,?,?,?,?,?) ON CONFLICT(username,ip,user_agent) DO UPDATE SET
|
||||
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at),
|
||||
hit_count=hit_count+excluded.hit_count''', (values['username'], entry['ip'], entry['user_agent'], entry['first_seen_at'], entry['last_seen_at'], entry['hit_count']))
|
||||
for name in names:
|
||||
conn.execute('DELETE FROM password_reset_tokens WHERE username=? COLLATE NOCASE', (name,))
|
||||
for identity in removed:
|
||||
# Duplicate subscriptions are not inherited. Preserve delivery history and cancel outstanding work.
|
||||
for table in ('email_recap_deliveries', 'newsletter_deliveries'):
|
||||
conn.execute(f"UPDATE {table} SET state='cancelled',detail='Duplicate account consolidated.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (identity,))
|
||||
conn.execute(f'UPDATE {table} SET user_id=? WHERE user_id=?', (keep, identity))
|
||||
conn.execute('DELETE FROM jellyfin_user_links WHERE local_user_id=?', (identity,))
|
||||
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 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))
|
||||
for feature, enabled in values['features'].items():
|
||||
if feature != 'invites':
|
||||
conn.execute('''INSERT INTO user_feature_permissions VALUES(?,?,?)
|
||||
ON CONFLICT(user_id,feature) DO UPDATE SET enabled=excluded.enabled''', (keep, feature, int(enabled)))
|
||||
conn.execute('''INSERT INTO jellyfin_user_links VALUES(?,?,?) ON CONFLICT(source,local_user_id)
|
||||
DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id''', (source_key(runtime.jellyfin_base_url), keep, values['jellyfin_user_id']))
|
||||
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (keep,))
|
||||
conn.execute('''INSERT INTO user_identity_confirmations VALUES(?,?,?,?,?,?,?,?)''',
|
||||
(keep, report['server_id'], values['jellyfin_user_id'], source_key(runtime.jellyfin_base_url),
|
||||
source_key(runtime.jellyseerr_base_url), values['seerr_user_id'], now, admin['username']))
|
||||
return {'kept_user_id': keep, 'consolidated': len(removed), 'repaired_at': now}
|
||||
|
||||
|
||||
async def repair_duplicates(user_id, keep_id=None, revision=None, admin=None):
|
||||
preview, report, local, runtime, state = await prepare(user_id, keep_id)
|
||||
if revision is None:
|
||||
return preview
|
||||
if revision != preview['revision']:
|
||||
raise HTTPException(409, 'The duplicate-account preview changed. Preview again before saving.')
|
||||
return await asyncio.to_thread(consolidate, preview, report, local, runtime, state, admin)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Shared claim and completion rules for the two durable email queues."""
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def queue_table(table: str) -> str:
|
||||
if table not in {"email_recap_deliveries", "newsletter_deliveries"}:
|
||||
raise ValueError("Unknown email queue")
|
||||
return table
|
||||
|
||||
|
||||
def claim(conn, table: str, now: float) -> dict | None:
|
||||
table = queue_table(table)
|
||||
conn.execute(f"""UPDATE {table} SET state='unknown', detail='Delivery interrupted after sending began; check the mail server.', updated_at=?
|
||||
WHERE state='sending' AND lease_until<?""", (now, now))
|
||||
conn.execute(f"""UPDATE {table} SET state=CASE WHEN attempts>=3 THEN 'failed' ELSE 'retry' END,
|
||||
next_attempt_at=?, updated_at=?, detail='Email preparation interrupted.'
|
||||
WHERE state='preparing' AND lease_until<?""", (now, now, now))
|
||||
row = conn.execute(f"""SELECT * FROM {table} WHERE state IN ('queued', 'retry') AND next_attempt_at<=?
|
||||
ORDER BY created_at, id LIMIT 1""", (now,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
claim_id = uuid.uuid4().hex
|
||||
conn.execute(f"""UPDATE {table} SET state='preparing', claim=?, lease_until=?,
|
||||
attempts=attempts+1, updated_at=? WHERE id=?""", (claim_id, now + 1800, now, row["id"]))
|
||||
return dict(conn.execute(f"SELECT * FROM {table} WHERE id=?", (row["id"],)).fetchone())
|
||||
|
||||
|
||||
def finish(conn, table: str, delivery: dict, state: str, detail: str, now: float, delay: int = 0):
|
||||
table = queue_table(table)
|
||||
conn.execute(f"""UPDATE {table} SET state=?, detail=?, updated_at=?, next_attempt_at=?, lease_until=NULL
|
||||
WHERE id=? AND claim=? AND state IN ('preparing', 'sending')""",
|
||||
(state, detail, now, now + delay, delivery["id"], delivery["claim"]))
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Opt-in monthly recaps. Scheduling and delivery are safe to run in multiple workers."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .. import db
|
||||
from ..clients.jellystat import HistoryLimitError, JellystatError
|
||||
from ..runtime import get_runtime_settings
|
||||
from . import recap_email as mail, recap_store as store
|
||||
from .invite_email import smtp_email_config_ready
|
||||
from .jellyfin_identity import linked_user_id, source_key
|
||||
from .monthly_reports import get_monthly_report, month_periods
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecapError(Exception):
|
||||
def __init__(self, detail: str, status: int = 409):
|
||||
self.detail, self.status = detail, status
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
def worker_enabled() -> bool:
|
||||
return os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() != "false"
|
||||
|
||||
|
||||
def delivery_ready() -> tuple[bool, str]:
|
||||
config = store.settings()
|
||||
if not config["public_url"]:
|
||||
return False, "Set the application URL in Hosting & proxy for email links."
|
||||
ready, detail = smtp_email_config_ready()
|
||||
if not ready:
|
||||
return False, detail
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.jellystat_base_url or not runtime.jellystat_api_key:
|
||||
return False, "Connect Jellystat to generate viewing recaps."
|
||||
if not worker_enabled():
|
||||
return False, "Background automation is paused on this server."
|
||||
return True, "Email delivery is configured."
|
||||
|
||||
|
||||
def current_account(user: dict) -> dict:
|
||||
account = db.get_user_by_username(user.get("username", ""))
|
||||
if not account or account.get("is_blocked") or account.get("is_expired"):
|
||||
raise RecapError("This account cannot receive viewing recaps.", 403)
|
||||
return account
|
||||
|
||||
|
||||
def binding_matches(sub: dict, account: dict) -> bool:
|
||||
runtime = get_runtime_settings()
|
||||
return bool(account and not account.get("is_blocked") and not account.get("is_expired")
|
||||
and mail.valid_email(account.get("email"))
|
||||
and account["email"].strip().casefold() == sub["email"].strip().casefold()
|
||||
and source_key(runtime.jellyfin_base_url) == sub["identity_source"]
|
||||
and linked_user_id(account["username"], runtime.jellyfin_base_url) == sub["identity_id"])
|
||||
|
||||
|
||||
def active_subscription(account: dict) -> dict | None:
|
||||
sub = store.subscription(account["id"])
|
||||
if sub and sub["state"] != "off" and not binding_matches(sub, account):
|
||||
store.disable(account["id"])
|
||||
sub = store.subscription(account["id"])
|
||||
return sub
|
||||
|
||||
|
||||
def preferences(user: dict) -> dict:
|
||||
account = current_account(user)
|
||||
sub = active_subscription(account)
|
||||
config = store.settings()
|
||||
ready, detail = delivery_ready()
|
||||
runtime = get_runtime_settings()
|
||||
linked = bool(linked_user_id(account["username"], runtime.jellyfin_base_url))
|
||||
email = mail.valid_email(account.get("email"))
|
||||
state = sub["state"] if sub else "off"
|
||||
if state == "pending" and sub["confirmation_expires"] <= time.time():
|
||||
state = "expired"
|
||||
return {"state": state, "email": account.get("email"), "can_subscribe": ready and linked and bool(email),
|
||||
"detail": detail if not ready else "Save a valid email address in your profile." if not email else
|
||||
"Your Jellyfin account needs a saved identity link." if not linked else "Your monthly story, in your inbox.",
|
||||
"automatic_monthly": bool(sub["automatic_monthly"]) if sub else False,
|
||||
"can_send": ready and state == "enabled", "deliveries": store.personal_history(account["id"]),
|
||||
"schedule_enabled": config["enabled"], "next_send_at": config["next_send_at"],
|
||||
"day": config["day"], "hour": config["hour"], "timezone": "UTC",
|
||||
"resend_after": (sub["requested_at"] + 300) if sub else None}
|
||||
|
||||
|
||||
async def subscribe(user: dict, automatic_monthly: bool | None = None) -> dict:
|
||||
account = current_account(user)
|
||||
preference = preferences(user)
|
||||
automatic = preference['automatic_monthly'] if automatic_monthly is None else automatic_monthly
|
||||
if preference["state"] == "enabled":
|
||||
store.set_automatic(account['id'], automatic)
|
||||
return preferences(user)
|
||||
if not preference["can_subscribe"]:
|
||||
raise RecapError(preference["detail"])
|
||||
config = store.settings()
|
||||
runtime = get_runtime_settings()
|
||||
try:
|
||||
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
|
||||
linked_user_id(account["username"], runtime.jellyfin_base_url), time.time(), automatic)
|
||||
except ValueError as exc:
|
||||
raise RecapError(str(exc), 429) from exc
|
||||
url = f"{config['public_url']}/email-recaps#" + urlencode({"action": "confirm", "token": token})
|
||||
rendered = mail.render_confirmation(account["username"], url)
|
||||
try:
|
||||
await asyncio.to_thread(mail.send_email, account["email"].strip(), rendered,
|
||||
mail.message_id(uuid.uuid4().hex, config["public_url"]))
|
||||
except mail.DeliveryError as exc:
|
||||
raise RecapError("Could not confirm delivery of the verification email. Check your inbox; you can request another in five minutes.", 502) from exc
|
||||
return {**preferences(user), "message": "Check your inbox and confirm within 24 hours to enable personal report emails."}
|
||||
|
||||
|
||||
def token_action(token: str, action: str, *, apply: bool = False) -> dict:
|
||||
sub = store.token_subscription(token, action)
|
||||
if not sub:
|
||||
raise RecapError("This email link is invalid or has already been used. Open Profile to manage your recaps.", 410)
|
||||
if action == "unsubscribe":
|
||||
if apply:
|
||||
store.disable(sub["user_id"])
|
||||
return {"action": action, "state": "off" if apply or sub["state"] == "off" else "ready"}
|
||||
account = db.get_user_by_id(sub["user_id"])
|
||||
if (sub["state"] != "pending" or sub["confirmation_expires"] <= time.time()
|
||||
or not binding_matches(sub, account)):
|
||||
raise RecapError("This confirmation has expired or your account details changed. Request a new link from Profile.", 410)
|
||||
if apply and not store.confirm(sub, time.time()):
|
||||
raise RecapError("This confirmation is no longer available. Request a new link from Profile.", 410)
|
||||
return {"action": action, "state": "enabled" if apply else "ready"}
|
||||
|
||||
|
||||
def completed_month(month: str | None) -> str:
|
||||
try:
|
||||
period = month_periods(month, datetime.now(timezone.utc))
|
||||
except ValueError as exc:
|
||||
raise RecapError(str(exc), 422) from exc
|
||||
if period["is_partial"]:
|
||||
raise RecapError("Choose a completed month for an email recap.", 422)
|
||||
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("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:
|
||||
raise RecapError("This report exceeds Jellystat's history limit. No partial recap was generated.", 422) from exc
|
||||
except (JellystatError, TimeoutError) as exc:
|
||||
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"), **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:
|
||||
account = current_account(user)
|
||||
ready, detail = delivery_ready()
|
||||
if not ready:
|
||||
raise RecapError(detail)
|
||||
sub = active_subscription(account)
|
||||
if not sub or sub["state"] != "enabled":
|
||||
raise RecapError("Turn on email recaps and confirm your email in Profile before sending a personal test.")
|
||||
selected = completed_month(month)
|
||||
try:
|
||||
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()["public_url"], time.time())
|
||||
except ValueError as exc:
|
||||
raise RecapError(str(exc), 429) from exc
|
||||
return {"id": delivery_id, "message": "Test queued for your confirmed email. Check delivery history for the result."}
|
||||
|
||||
|
||||
def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
|
||||
account = db.get_user_by_id(delivery["user_id"])
|
||||
from ..feature_access import permissions
|
||||
if not account or not permissions(account)["stats"]:
|
||||
raise mail.DeliveryCancelled()
|
||||
sub = active_subscription(account) if account else None
|
||||
config = store.settings()
|
||||
ready, _ = delivery_ready()
|
||||
if (not ready or not sub or sub["state"] != "enabled" or sub["version"] != delivery["subscription_version"]
|
||||
or sub["email"] != delivery["email"] or not binding_matches(sub, account)
|
||||
or config["public_url"] != delivery["public_url"]
|
||||
or (delivery["kind"] == "scheduled" and (not config["enabled"] or not sub["automatic_monthly"]))):
|
||||
raise mail.DeliveryCancelled()
|
||||
return account, sub
|
||||
|
||||
|
||||
async def process_delivery(delivery: dict) -> None:
|
||||
state, detail, delay = "failed", "Could not prepare the recap. Check the report and email settings.", 0
|
||||
try:
|
||||
account, sub = eligible_delivery(delivery)
|
||||
report = await asyncio.wait_for(get_monthly_report(account, delivery["month"]), timeout=180)
|
||||
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 = await illustrated_recap(report, account, delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
|
||||
|
||||
def before_data():
|
||||
eligible_delivery(delivery)
|
||||
if not store.begin_sending(delivery, time.time()):
|
||||
raise mail.DeliveryCancelled()
|
||||
|
||||
await asyncio.to_thread(mail.send_email, delivery["email"], rendered,
|
||||
mail.message_id(delivery["id"], delivery["public_url"]), before_data)
|
||||
state, detail = "sent", "Accepted by the mail server."
|
||||
except mail.DeliveryCancelled:
|
||||
state, detail = "cancelled", "Consent, account details or email configuration changed."
|
||||
except HistoryLimitError:
|
||||
state, detail = "failed", "Jellystat's history limit was reached. No partial recap was sent."
|
||||
except (JellystatError, TimeoutError):
|
||||
state, detail = "retry", "Viewing history is temporarily unavailable."
|
||||
except mail.DeliveryError as exc:
|
||||
state, detail = exc.state, exc.detail
|
||||
except Exception as exc:
|
||||
# Do not expose provider errors or private report content in history/logs.
|
||||
logger.error("recap delivery error id=%s type=%s", delivery["id"], type(exc).__name__)
|
||||
row = store.read_one("SELECT state FROM email_recap_deliveries WHERE id=?", (delivery["id"],))
|
||||
if row and row["state"] == "sending":
|
||||
state, detail = "unknown", "Delivery outcome is unknown; check the mail server."
|
||||
if state == "retry":
|
||||
if delivery["attempts"] >= 3:
|
||||
state, detail = "failed", detail + " Stopped after three attempts."
|
||||
else:
|
||||
delay = 300 if delivery["attempts"] == 1 else 1800
|
||||
store.finish(delivery, state, detail, time.time(), delay)
|
||||
|
||||
|
||||
async def run_once() -> None:
|
||||
store.enqueue_due(datetime.now(timezone.utc))
|
||||
for _ in range(10):
|
||||
delivery = store.claim_delivery(time.time())
|
||||
if not delivery:
|
||||
break
|
||||
await process_delivery(delivery)
|
||||
|
||||
|
||||
async def run_email_recap_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await run_once()
|
||||
except Exception as exc:
|
||||
logger.error("email recap worker failed type=%s", type(exc).__name__)
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
def queue_personal(user: dict, month: str | None, request_id: str) -> dict:
|
||||
account = current_account(user)
|
||||
ready, detail = delivery_ready()
|
||||
if not ready:
|
||||
raise RecapError(detail)
|
||||
sub = active_subscription(account)
|
||||
if not sub or sub['state'] != 'enabled':
|
||||
raise RecapError('Confirm your profile email in email preferences before emailing a report.')
|
||||
try:
|
||||
selected = month_periods(month, datetime.now(timezone.utc))['month']
|
||||
except ValueError as exc:
|
||||
raise RecapError(str(exc), 422) from exc
|
||||
try:
|
||||
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()['public_url'], time.time(), 'on_demand')
|
||||
except ValueError as exc:
|
||||
raise RecapError(str(exc), 429) from exc
|
||||
return {'id': delivery_id, 'message': 'Your report is queued for your confirmed profile email. Delivery status appears below.'}
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
from collections import defaultdict
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .. import db
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..clients.jellystat import JellystatClient
|
||||
from ..runtime import get_runtime_settings
|
||||
from .jellyfin_identity import source_key
|
||||
|
||||
MAX_USERS = 3000
|
||||
CONFIG_KEYS = ("jellyfin_base_url", "jellyfin_api_key", "jellyseerr_base_url",
|
||||
"jellyseerr_api_key", "jellystat_base_url", "jellystat_api_key")
|
||||
|
||||
|
||||
def normalized_id(value):
|
||||
value = str(value or "").lower().replace("-", "")
|
||||
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
|
||||
|
||||
|
||||
def name_key(value):
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def config_digest(runtime):
|
||||
return digest([getattr(runtime, key, None) for key in CONFIG_KEYS])
|
||||
|
||||
|
||||
def snapshot(conn):
|
||||
conn.row_factory = sqlite3.Row
|
||||
return {
|
||||
"users": [dict(row) for row in conn.execute(
|
||||
"SELECT id, username, role, auth_provider, jellyseerr_user_id FROM users ORDER BY id")],
|
||||
"links": [dict(row) for row in conn.execute(
|
||||
"SELECT source, local_user_id, jellyfin_user_id FROM jellyfin_user_links ORDER BY source, local_user_id")],
|
||||
"confirmations": [dict(row) for row in conn.execute(
|
||||
"SELECT * FROM user_identity_confirmations ORDER BY local_user_id")],
|
||||
# Detect settings changes between checking services and committing the reviewed links.
|
||||
"config_revision": digest([tuple(row) for row in conn.execute(
|
||||
"SELECT key, value FROM settings WHERE key IN (" + ",".join("?" for _ in CONFIG_KEYS) + ") ORDER BY key", CONFIG_KEYS)]),
|
||||
}
|
||||
|
||||
|
||||
def read_snapshot():
|
||||
with closing(db._connect()) as conn:
|
||||
return snapshot(conn)
|
||||
|
||||
|
||||
async def jellyfin_directory(runtime):
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
return {"state": "not_configured", "users": []}
|
||||
try:
|
||||
users, server = await asyncio.gather(client.get_users(), client.get_system_info())
|
||||
server_id = normalized_id(server.get("Id")) if isinstance(server, dict) else None
|
||||
if not server_id or not isinstance(users, list) or len(users) > MAX_USERS:
|
||||
raise ValueError()
|
||||
clean = []
|
||||
seen = set()
|
||||
for user in users:
|
||||
user_id = normalized_id(user.get("Id"))
|
||||
if not user_id or user_id in seen or normalized_id(user.get("ServerId")) != server_id:
|
||||
raise ValueError()
|
||||
seen.add(user_id)
|
||||
clean.append({"id": user_id, "name": str(user.get("Name") or "")[:200]})
|
||||
return {"state": "available", "server_id": server_id, "users": sorted(clean, key=lambda row: row["id"])}
|
||||
except Exception:
|
||||
return {"state": "unavailable", "users": []}
|
||||
|
||||
|
||||
async def seerr_directory(runtime):
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.base_url or not client.api_key:
|
||||
return {"state": "not_configured", "users": []}
|
||||
try:
|
||||
users = []
|
||||
seen = set()
|
||||
expected_total = None
|
||||
async with asyncio.timeout(20):
|
||||
for skip in range(0, MAX_USERS, 100):
|
||||
page = await client.get_users(take=100, skip=skip)
|
||||
total = page["pageInfo"]["results"]
|
||||
batch = page["results"]
|
||||
if type(total) is not int or total < 0 or total > MAX_USERS or not isinstance(batch, list):
|
||||
raise ValueError()
|
||||
if expected_total is not None and total != expected_total:
|
||||
raise ValueError()
|
||||
expected_total = total
|
||||
for user in batch:
|
||||
user_id = user.get("id")
|
||||
if type(user_id) is not int or user_id <= 0 or user_id in seen:
|
||||
raise ValueError()
|
||||
seen.add(user_id)
|
||||
users.append({"id": user_id, "name": str(user.get("displayName") or user.get("jellyfinUsername") or "")[:200],
|
||||
"jellyfin_id": normalized_id(user.get("jellyfinUserId"))})
|
||||
if len(users) == total:
|
||||
return {"state": "available", "users": sorted(users, key=lambda row: row["id"])}
|
||||
if len(batch) != 100 or len(users) > total:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
pass
|
||||
return {"state": "unavailable", "users": []}
|
||||
|
||||
|
||||
def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None, repair=False):
|
||||
original = local
|
||||
selections = selections or {}
|
||||
if repair:
|
||||
local = copy.deepcopy(local)
|
||||
for user in local['users']:
|
||||
if user['id'] in selections:
|
||||
user['jellyseerr_user_id'] = None
|
||||
local['links'] = [link for link in local['links'] if not (
|
||||
link['local_user_id'] in selections and link['source'] == source_key(runtime.jellyfin_base_url))]
|
||||
local['confirmations'] = [item for item in local['confirmations'] if item['local_user_id'] not in selections]
|
||||
if any(user_id not in {user['id'] for user in local['users']} for user_id in selections):
|
||||
raise HTTPException(404, "This Magent account no longer exists. Run the check again.")
|
||||
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
|
||||
jf_by_name = defaultdict(list)
|
||||
for row in jellyfin["users"]:
|
||||
jf_by_name[name_key(row["name"])].append(row["id"])
|
||||
seerr_by_id = {row["id"]: row for row in seerr["users"]}
|
||||
seerr_by_jf = defaultdict(list)
|
||||
for row in seerr["users"]:
|
||||
if row["jellyfin_id"]:
|
||||
seerr_by_jf[row["jellyfin_id"]].append(row)
|
||||
current_source = source_key(runtime.jellyfin_base_url)
|
||||
seerr_source = source_key(runtime.jellyseerr_base_url)
|
||||
links = {row["local_user_id"]: normalized_id(row["jellyfin_user_id"]) for row in local["links"] if row["source"] == current_source}
|
||||
confirmed = {row["local_user_id"]: row for row in local["confirmations"]}
|
||||
local_by_name, local_by_seerr = defaultdict(list), defaultdict(list)
|
||||
for user in local["users"]:
|
||||
local_by_name[name_key(user["username"])].append(user["id"])
|
||||
if user["jellyseerr_user_id"] is not None:
|
||||
local_by_seerr[user["jellyseerr_user_id"]].append(user["id"])
|
||||
rows = []
|
||||
for user in local["users"]:
|
||||
issues = []
|
||||
saved = confirmed.get(user["id"])
|
||||
linked = links.get(user["id"])
|
||||
stored_seerr = seerr_by_id.get(user["jellyseerr_user_id"])
|
||||
by_name = jf_by_name.get(name_key(user["username"]), [])
|
||||
basis = "none"
|
||||
candidate = None
|
||||
if saved:
|
||||
candidate = saved["jellyfin_user_id"]
|
||||
basis = "confirmed_id"
|
||||
if saved["jellyfin_server_id"] != jellyfin.get("server_id") or saved["seerr_source"] != seerr_source:
|
||||
issues.append("The confirmed server or Seerr connection has changed.")
|
||||
elif linked:
|
||||
candidate, basis = linked, "stored_jellyfin_id"
|
||||
elif stored_seerr and stored_seerr["jellyfin_id"]:
|
||||
candidate, basis = stored_seerr["jellyfin_id"], "stored_seerr_id"
|
||||
elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
|
||||
candidate, basis = by_name[0], "suggested_username"
|
||||
if repair and user['id'] in selections and any(
|
||||
item['local_user_id'] == user['id'] and item['jellyfin_server_id'] != jellyfin.get('server_id')
|
||||
for item in original['confirmations']):
|
||||
issues.append('The Jellyfin server changed. A server migration requires separate review.')
|
||||
if user["id"] in selections:
|
||||
chosen = selections[user["id"]]
|
||||
if saved and chosen != saved["jellyfin_user_id"]:
|
||||
issues.append("A confirmed identity cannot be replaced through missing-link resolution.")
|
||||
candidate, basis = chosen, "admin_selected"
|
||||
if len(local_by_name[name_key(user["username"])]) > 1:
|
||||
issues.append("Multiple Magent rows share this username after case and whitespace normalization.")
|
||||
if len(local_by_seerr.get(user["jellyseerr_user_id"], [])) > 1:
|
||||
issues.append("Multiple Magent rows share the stored Seerr ID.")
|
||||
if len(by_name) > 1:
|
||||
issues.append("This name matches multiple distinct Jellyfin IDs.")
|
||||
if candidate and by_name and candidate not in by_name:
|
||||
issues.append("The stored ID and current Jellyfin username point to different accounts.")
|
||||
if linked and candidate and linked != candidate:
|
||||
issues.append("The stored Jellyfin link conflicts with the confirmed identity.")
|
||||
jf = jf_by_id.get(candidate)
|
||||
if candidate and not jf and jellyfin["state"] == "available":
|
||||
issues.append("The linked Jellyfin ID is absent from the current server.")
|
||||
expected_seerr = seerr_by_jf.get(candidate, [])
|
||||
if len(expected_seerr) > 1:
|
||||
issues.append("Multiple Seerr users reference the same Jellyfin ID.")
|
||||
if user["jellyseerr_user_id"] is not None and seerr["state"] == "available" and (
|
||||
len(expected_seerr) != 1 or expected_seerr[0]["id"] != user["jellyseerr_user_id"]
|
||||
):
|
||||
issues.append("The stored Seerr ID does not match Seerr's Jellyfin ID mapping.")
|
||||
if saved and user["jellyseerr_user_id"] != saved["seerr_user_id"]:
|
||||
issues.append("The stored Seerr ID has changed since confirmation.")
|
||||
js = jellystat.get(candidate, {"state": "not_checked"})
|
||||
rows.append({"user": user, "jellyfin": jf, "candidate_jellyfin_id": candidate,
|
||||
"stored_jellyfin_id": linked, "seerr": expected_seerr, "jellystat": js,
|
||||
"basis": basis, "issues": issues, "confirmed_at": saved["confirmed_at"] if saved else None,
|
||||
"can_confirm": False, "state": "unlinked"})
|
||||
candidates = defaultdict(list)
|
||||
for row in rows:
|
||||
if row["candidate_jellyfin_id"]:
|
||||
candidates[row["candidate_jellyfin_id"]].append(row)
|
||||
for row in rows:
|
||||
candidate = row["candidate_jellyfin_id"]
|
||||
if len(candidates.get(candidate, [])) > 1:
|
||||
row["issues"].append("Multiple Magent accounts resolve to this Jellyfin ID.")
|
||||
# Also protect IDs already reserved by a link/confirmation whose local user was deleted.
|
||||
if any(link["local_user_id"] != row["user"]["id"] and link["source"] == current_source
|
||||
and normalized_id(link["jellyfin_user_id"]) == candidate for link in local["links"]) or any(
|
||||
item["local_user_id"] != row["user"]["id"] and item["jellyfin_server_id"] == jellyfin.get("server_id")
|
||||
and item["jellyfin_user_id"] == candidate for item in local["confirmations"]):
|
||||
row["issues"].append("This Jellyfin ID is already reserved by another Magent account.")
|
||||
if row["issues"]:
|
||||
row["state"] = "conflict"
|
||||
elif jellyfin["state"] != "available" or seerr["state"] != "available" or (candidate and row["jellystat"]["state"] in {"unavailable", "not_configured"}):
|
||||
row["state"] = "unavailable"
|
||||
elif not row["jellyfin"] or not row["seerr"] or row["jellystat"]["state"] != "matched":
|
||||
row["state"] = "unlinked"
|
||||
elif row["confirmed_at"] and row["stored_jellyfin_id"] == candidate:
|
||||
row["state"] = "confirmed"
|
||||
else:
|
||||
row["state"] = "ready"
|
||||
row["can_confirm"] = True
|
||||
upstream = [{"platform": "Seerr", "id": str(row["id"]), "name": row["name"], "jellyfin_id": row["jellyfin_id"],
|
||||
"detail": "No current Jellyfin account has this ID."} for row in seerr["users"]
|
||||
if row["jellyfin_id"] not in jf_by_id and jellyfin["state"] == "available"]
|
||||
upstream += [{"platform": "Jellyfin", "id": row["id"], "name": row["name"], "jellyfin_id": row["id"],
|
||||
"detail": "No Magent account resolves to this ID."} for row in jellyfin["users"] if row["id"] not in candidates]
|
||||
services = {"jellyfin": jellyfin["state"], "seerr": seerr["state"],
|
||||
"jellystat": "not_configured" if not runtime.jellystat_base_url or not runtime.jellystat_api_key else
|
||||
"not_checked" if not jellystat else
|
||||
"unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"}
|
||||
report = {"server_id": jellyfin.get("server_id"), "services": services, "rows": rows, "upstream": upstream,
|
||||
"jellyfin_users": jellyfin["users"], "seerr_users": seerr["users"],
|
||||
"counts": {"magent": len(rows), "jellyfin": len(jellyfin["users"]), "seerr": len(seerr["users"]),
|
||||
"jellystat_checked": sum(r["state"] in {"matched", "missing"} for r in jellystat.values()),
|
||||
**{state: sum(row["state"] == state for row in rows) for state in ("ready", "confirmed", "conflict", "unlinked", "unavailable")}}}
|
||||
report["revision"] = digest([report, digest(original), config_digest(runtime), repair])
|
||||
report["checked_at"] = datetime.now(timezone.utc).isoformat()
|
||||
return report
|
||||
|
||||
|
||||
async def review_identities(selections=None, repair=False):
|
||||
runtime = await asyncio.to_thread(get_runtime_settings)
|
||||
local, jf, seerr = await asyncio.gather(asyncio.to_thread(read_snapshot), jellyfin_directory(runtime), seerr_directory(runtime))
|
||||
if len(local["users"]) > MAX_USERS:
|
||||
raise HTTPException(422, "The identity check supports up to 3,000 Magent accounts.")
|
||||
ids = {row["id"] for row in jf["users"]}
|
||||
ids.update(row["jellyfin_id"] for row in seerr["users"] if row["jellyfin_id"])
|
||||
ids.update(normalized_id(row["jellyfin_user_id"]) for row in local["links"])
|
||||
ids.discard(None)
|
||||
if len(ids) > MAX_USERS:
|
||||
raise HTTPException(422, "There are too many upstream IDs for one identity check.")
|
||||
stats_client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
js = await stats_client.check_user_ids(sorted(ids)) if stats_client.configured() else {key: {"state": "not_configured"} for key in ids}
|
||||
return build_report(local, jf, seerr, js, runtime, selections, repair), local, runtime
|
||||
|
||||
|
||||
def save_confirmations(report, local, runtime, user_ids, admin, repair=False):
|
||||
rows = {row["user"]["id"]: row for row in report["rows"]}
|
||||
if any(user_id not in rows or not rows[user_id]["can_confirm"] for user_id in user_ids):
|
||||
raise HTTPException(409, "Some selected accounts cannot be confirmed. Run the check again and review the conflicts.")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if digest(snapshot(conn)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
|
||||
raise HTTPException(409, "Accounts or settings changed during confirmation. Run the check again.")
|
||||
for user_id in user_ids:
|
||||
row = rows[user_id]
|
||||
jf_id = row["jellyfin"]["id"]
|
||||
seerr_id = row["seerr"][0]["id"]
|
||||
conn.execute("""INSERT INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(source,local_user_id) DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id""",
|
||||
(source_key(runtime.jellyfin_base_url), user_id, jf_id))
|
||||
conn.execute("UPDATE users SET jellyseerr_user_id=? WHERE id=?", (seerr_id, user_id))
|
||||
conn.execute("""INSERT INTO user_identity_confirmations
|
||||
(local_user_id,jellyfin_server_id,jellyfin_user_id,jellyfin_source,seerr_source,seerr_user_id,confirmed_at,confirmed_by)
|
||||
VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(local_user_id) DO UPDATE SET
|
||||
jellyfin_source=excluded.jellyfin_source,confirmed_at=excluded.confirmed_at,confirmed_by=excluded.confirmed_by""",
|
||||
(user_id, report["server_id"], jf_id, source_key(runtime.jellyfin_base_url), source_key(runtime.jellyseerr_base_url),
|
||||
seerr_id, now, admin["username"]))
|
||||
if repair:
|
||||
before_user = next(user for user in local['users'] if user['id'] == user_id)
|
||||
before = {'seerr_user_id': before_user['jellyseerr_user_id'],
|
||||
'links': [link for link in local['links'] if link['local_user_id'] == user_id],
|
||||
'confirmation': next((item for item in local['confirmations'] if item['local_user_id'] == user_id), None)}
|
||||
conn.execute("""UPDATE user_identity_confirmations SET jellyfin_server_id=?,jellyfin_user_id=?,
|
||||
jellyfin_source=?,seerr_source=?,seerr_user_id=? WHERE local_user_id=?""",
|
||||
(report['server_id'], jf_id, source_key(runtime.jellyfin_base_url),
|
||||
source_key(runtime.jellyseerr_base_url), seerr_id, user_id))
|
||||
conn.execute("""INSERT INTO user_identity_repairs
|
||||
(local_user_id,before_json,after_json,repaired_at,repaired_by) VALUES (?,?,?,?,?)""",
|
||||
(user_id, json.dumps(before, sort_keys=True), json.dumps({
|
||||
'jellyfin_server_id': report['server_id'], 'jellyfin_user_id': jf_id,
|
||||
'seerr_user_id': seerr_id}, sort_keys=True), now, admin['username']))
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(409, "An identity is already linked to another account. Run the check again.") from exc
|
||||
return {"confirmed": len(user_ids), "confirmed_at": now}
|
||||
|
||||
|
||||
async def confirm_identities(revision, user_ids, admin):
|
||||
report, local, runtime = await review_identities()
|
||||
if report["revision"] != revision:
|
||||
raise HTTPException(409, "The identity check has changed. Run it again before confirming accounts.")
|
||||
return await asyncio.to_thread(save_confirmations, report, local, runtime, user_ids, admin)
|
||||
|
||||
|
||||
async def resolve_identity(user_id, jellyfin_user_id, revision=None, admin=None):
|
||||
report, local, runtime = await review_identities({user_id: jellyfin_user_id})
|
||||
if revision is not None:
|
||||
if report["revision"] != revision:
|
||||
raise HTTPException(409, "Accounts or service mappings changed. Check the selected account again before saving.")
|
||||
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin)
|
||||
return {"revision": report["revision"], "server_id": report["server_id"],
|
||||
"row": next(row for row in report["rows"] if row["user"]["id"] == user_id)}
|
||||
|
||||
|
||||
async def repair_identity(user_id, jellyfin_user_id, revision=None, admin=None, create_seerr=False):
|
||||
report, local, runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
|
||||
row = next(row for row in report['rows'] if row['user']['id'] == user_id)
|
||||
importing = bool(create_seerr and row['state'] == 'unlinked' and row['jellyfin']
|
||||
and not row['seerr'] and row['jellystat']['state'] == 'matched'
|
||||
and report['services']['seerr'] == 'available')
|
||||
if importing and any(name_key(account['name']) == name_key(row['jellyfin']['name'])
|
||||
for account in report['seerr_users']):
|
||||
importing = False
|
||||
row['issues'].append('A Seerr account already has this name. Review its existing link before importing.')
|
||||
report['revision'] = digest([report['revision'], create_seerr])
|
||||
if revision is not None:
|
||||
if report['revision'] != revision:
|
||||
raise HTTPException(409, 'The repair preview changed. Check the selected account again.')
|
||||
if importing:
|
||||
if digest(await asyncio.to_thread(read_snapshot)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
|
||||
raise HTTPException(409, 'Accounts or settings changed. Preview the repair again.')
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
try:
|
||||
await client.post('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [jellyfin_user_id]})
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise HTTPException(502, 'The Seerr import could not be verified. Run a fresh check before trying again; an account may already have been imported.') from exc
|
||||
# Upstream and SQLite cannot share a transaction. Reconcile using live IDs;
|
||||
# never delete an imported account if the local save is blocked or interrupted.
|
||||
refreshed, _, fresh_runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
|
||||
if config_digest(fresh_runtime) != config_digest(runtime):
|
||||
raise HTTPException(409, 'Seerr import completed but settings changed. Check accounts again before saving Magent links.')
|
||||
try:
|
||||
return await asyncio.to_thread(save_confirmations, refreshed, local, runtime, [user_id], admin, True)
|
||||
except HTTPException as exc:
|
||||
raise HTTPException(409, 'Seerr import completed, but Magent links could not be saved. Run another check to review the imported account. No account was deleted.') from exc
|
||||
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin, True)
|
||||
before = next(user for user in local['users'] if user['id'] == user_id)
|
||||
linked = next((link['jellyfin_user_id'] for link in local['links'] if link['local_user_id'] == user_id
|
||||
and link['source'] == source_key(runtime.jellyfin_base_url)), None)
|
||||
row['can_confirm'] = row['can_confirm'] or importing
|
||||
return {'revision': report['revision'], 'server_id': report['server_id'], 'row': row,
|
||||
'action': 'import_seerr' if importing else 'repair_magent',
|
||||
'before': {'jellyfin_user_id': linked, 'seerr_user_id': before['jellyseerr_user_id']},
|
||||
'seerr_users': report['seerr_users'],
|
||||
'scope': ('Import this single Jellyfin account into Seerr, then verify and save Magent links. Existing Seerr accounts stay unchanged.' if importing else 'Repair Magent links only. Jellyfin and Jellystat IDs and Seerr accounts stay unchanged.')}
|
||||
@@ -0,0 +1,243 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import sqlite3
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .. import db
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellystat import JellystatClient, JellystatError
|
||||
from ..runtime import get_runtime_settings
|
||||
from .jellyfin_identity import link_user, linked_user_id
|
||||
from .insights_artwork import item_id as artwork_item_id, with_artwork
|
||||
|
||||
_cache: dict[tuple, tuple[float, dict]] = {}
|
||||
CACHE_SECONDS = 60
|
||||
|
||||
HARDWARE = {"amf": "AMD AMF", "qsv": "Intel Quick Sync", "nvenc": "NVIDIA NVENC",
|
||||
"v4l2m2m": "V4L2", "vaapi": "VAAPI", "videotoolbox": "Apple VideoToolbox", "rkmpp": "Rockchip MPP"}
|
||||
HARDWARE_ENUM = {0: "none", 1: "amf", 2: "qsv", 3: "nvenc", 4: "v4l2m2m", 5: "vaapi", 6: "videotoolbox", 7: "rkmpp"}
|
||||
|
||||
|
||||
def add_transcoding(row, duration, media_type, totals, hardware, audio_codecs):
|
||||
# Jellystat can retain stale transcoding metadata after a switch to DirectPlay.
|
||||
method = row.get("PlayMethod")
|
||||
if method not in {"Transcode", "DirectStream"}:
|
||||
return
|
||||
info = row.get("TranscodingInfo")
|
||||
if isinstance(info, str):
|
||||
try:
|
||||
info = json.loads(info)
|
||||
except ValueError:
|
||||
info = None
|
||||
info = info if isinstance(info, dict) else {}
|
||||
video_present = media_type in {"movie", "episode"} or bool(info.get("VideoCodec"))
|
||||
if method == "Transcode" and video_present:
|
||||
if info.get("IsVideoDirect") is False:
|
||||
totals["video_minutes"] += duration
|
||||
value = info.get("HardwareAccelerationType")
|
||||
value = HARDWARE_ENUM.get(value) if type(value) is int else str(value or "").strip().lower()
|
||||
if value in HARDWARE:
|
||||
totals["hardware_video_minutes"] += duration
|
||||
hardware[HARDWARE[value]] += duration
|
||||
elif value == "none":
|
||||
totals["software_video_minutes"] += duration
|
||||
else:
|
||||
totals["unknown_hardware_minutes"] += duration
|
||||
elif info.get("IsVideoDirect") is not True:
|
||||
totals["unknown_video_minutes"] += duration
|
||||
if info.get("IsAudioDirect") is False:
|
||||
totals["audio_minutes"] += duration
|
||||
codec = str(info.get("AudioCodec") or "Unknown").upper()[:30]
|
||||
audio_codecs[codec] += duration
|
||||
elif info.get("IsAudioDirect") is not True:
|
||||
totals["unknown_audio_minutes"] += duration
|
||||
|
||||
|
||||
def _date(value) -> datetime:
|
||||
try:
|
||||
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise JellystatError("Jellystat returned an invalid history date") from exc
|
||||
|
||||
|
||||
def _duration(value) -> float:
|
||||
try:
|
||||
result = float(value or 0)
|
||||
if not math.isfinite(result) or result < 0:
|
||||
raise ValueError()
|
||||
return result
|
||||
except (ValueError, TypeError, OverflowError) as exc:
|
||||
raise JellystatError("Jellystat returned an invalid playback duration") from exc
|
||||
|
||||
|
||||
async def resolve_identity(user: dict, runtime) -> str | None:
|
||||
identity = await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
|
||||
if identity:
|
||||
return identity
|
||||
if user.get("auth_provider") != "jellyfin":
|
||||
return None
|
||||
# Bootstrap existing Jellyfin accounts from the canonical server, using exact names.
|
||||
# Local accounts and email-prefix matches cannot claim a Jellyfin identity.
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
return None
|
||||
try:
|
||||
users = await client.get_users()
|
||||
except Exception as exc:
|
||||
raise JellystatError("Could not resolve the linked Jellyfin account") from exc
|
||||
matches = [entry for entry in users if isinstance(entry, dict)
|
||||
and str(entry.get("Name") or "").strip().casefold() == user["username"].strip().casefold()] if isinstance(users, list) else []
|
||||
if len(matches) != 1 or not matches[0].get("Id"):
|
||||
return None
|
||||
await asyncio.to_thread(link_user, user["username"], str(matches[0]["Id"]), runtime.jellyfin_base_url)
|
||||
return await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
|
||||
|
||||
|
||||
def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
|
||||
operator = "<" if end_exclusive else "<="
|
||||
clause = f"julianday(created_at) >= julianday(?) AND julianday(created_at) {operator} julianday(?)"
|
||||
params = [start.isoformat(), end.isoformat()]
|
||||
if user.get("jellyseerr_user_id") is not None:
|
||||
clause += " AND requested_by_id = ?"
|
||||
params.append(user["jellyseerr_user_id"])
|
||||
else:
|
||||
clause += " AND requested_by_id IS NULL AND lower(trim(requested_by)) = ?"
|
||||
params.append(user["username"].strip().lower())
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
counts = conn.execute(f"""SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(media_type = 'movie'), 0) AS movies,
|
||||
COALESCE(SUM(media_type = 'tv'), 0) AS tv,
|
||||
COALESCE(SUM(status = 1), 0) AS pending,
|
||||
COALESCE(SUM(status = 2), 0) AS approved,
|
||||
COALESCE(SUM(status = 3), 0) AS declined FROM requests_cache WHERE {clause}""", params).fetchone()
|
||||
recent = conn.execute(f"""SELECT request_id, title, media_type, status FROM requests_cache
|
||||
WHERE {clause} ORDER BY created_at DESC LIMIT 5""", params).fetchall()
|
||||
return {**dict(counts), "recent": [dict(row) for row in recent]}
|
||||
|
||||
|
||||
def summarize(history: list, libraries: list, start: datetime, end: datetime, *, 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",
|
||||
"unknown_hardware_minutes", "unknown_video_minutes", "unknown_audio_minutes"), 0.0)
|
||||
hardware, audio_codecs = defaultdict(float), defaultdict(float)
|
||||
titles = {}
|
||||
movie_ids, episode_ids, seen = set(), set(), set()
|
||||
recent = []
|
||||
seconds = 0.0
|
||||
for row in history:
|
||||
row_id = str(row.get("Id") or "")
|
||||
if not row_id:
|
||||
raise JellystatError("Jellystat returned history without an activity ID")
|
||||
if row_id in seen:
|
||||
continue
|
||||
seen.add(row_id)
|
||||
date = _date(row.get("ActivityDateInserted"))
|
||||
# Defend against older upstream versions ignoring the range filter.
|
||||
if date < start or (date >= end if end_exclusive else date > end):
|
||||
continue
|
||||
duration = _duration(row.get("PlaybackDuration"))
|
||||
if duration <= 0:
|
||||
continue
|
||||
item_id = str(row.get("NowPlayingItemId") or row_id)
|
||||
episode_id = row.get("EpisodeId")
|
||||
library_type = library_types.get(str(row.get("ParentId")), "")
|
||||
media_type = "episode" if episode_id else "movie" if library_type == "movies" else "other"
|
||||
add_transcoding(row, duration / 60, media_type, transcoding, hardware, audio_codecs)
|
||||
if media_type == "episode":
|
||||
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]
|
||||
clients[client] += duration
|
||||
method = str(row.get("PlayMethod") or "Unknown")
|
||||
method = {"DirectPlay": "Direct play", "DirectStream": "Direct stream", "Transcode": "Transcode"}.get(method, "Other")
|
||||
methods[method] += duration
|
||||
name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
|
||||
series = str(row.get("SeriesName") or "")[:500]
|
||||
title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0, "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,
|
||||
"episode": f"S{row.get('SeasonNumber', '?')} · E{row.get('EpisodeNumber', '?')}" if episode_id else None,
|
||||
"minutes": round(duration / 60, 1), "played_at": date.isoformat(), "client": client,
|
||||
"method": method, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
|
||||
last_date = (end - timedelta(microseconds=1)).date() if end_exclusive and end > start else end.date()
|
||||
count = (last_date - start.date()).days + 1
|
||||
daily = [{"date": (start.date() + timedelta(days=i)).isoformat(),
|
||||
"minutes": round(daily_seconds.get((start.date() + timedelta(days=i)).isoformat(), 0) / 60, 2)} for i in range(count)]
|
||||
active_days = {day for day, duration in daily_seconds.items() if duration >= 60}
|
||||
longest = run = 0
|
||||
for day in daily:
|
||||
run = run + 1 if day["date"] in active_days else 0
|
||||
longest = max(longest, run)
|
||||
current = 0
|
||||
cursor = last_date if last_date.isoformat() in active_days else last_date - timedelta(days=1)
|
||||
while cursor.isoformat() in active_days:
|
||||
current += 1
|
||||
cursor -= timedelta(days=1)
|
||||
top = sorted(titles.values(), key=lambda row: (-row["minutes"], row["title"]))[:6]
|
||||
for row in top:
|
||||
row["minutes"] = round(row["minutes"], 1)
|
||||
return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
|
||||
"episodes": len(episode_ids), "active_days": len(active_days),
|
||||
"current_streak": current, "longest_streak": longest},
|
||||
"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])],
|
||||
"transcoding": {**{name: round(value, 1) for name, value in transcoding.items()},
|
||||
"hardware": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(hardware.items(), key=lambda pair: -pair[1])],
|
||||
"audio_codecs": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(audio_codecs.items(), key=lambda pair: -pair[1])],
|
||||
"gpu_busy_minutes": None},
|
||||
"recent": sorted(recent, key=lambda row: row["played_at"], reverse=True)[:20]}
|
||||
|
||||
|
||||
async def get_insights(user: dict, days: int) -> dict:
|
||||
runtime = await asyncio.to_thread(get_runtime_settings)
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=days)
|
||||
requests = await asyncio.to_thread(request_summary, user, start, end)
|
||||
base = {"source": "Jellystat", "days": days, "timezone": "UTC", "requests": requests,
|
||||
"is_admin": user.get("role") == "admin", "summary": None}
|
||||
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
if not client.configured():
|
||||
return {**base, "state": "not_configured"}
|
||||
identity = await resolve_identity(user, runtime)
|
||||
if not identity:
|
||||
return {**base, "state": "unlinked"}
|
||||
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
|
||||
runtime.jellyfin_base_url, identity, days)
|
||||
cached = _cache.get(key)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
return {**base, **with_artwork(cached[1], user, runtime)}
|
||||
history, libraries = await client.get_user_history(identity, start, end)
|
||||
data = {**summarize(history, libraries, start, end), "state": "ready", "updated_at": end.isoformat(),
|
||||
"period_start": start.isoformat(), "period_end": end.isoformat()}
|
||||
for expired in [key for key, value in _cache.items() if value[0] <= time.monotonic()]:
|
||||
_cache.pop(expired, None)
|
||||
if len(_cache) >= 128:
|
||||
_cache.pop(next(iter(_cache)))
|
||||
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
|
||||
return {**base, **with_artwork(data, user, runtime)}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Private Jellyfin thumbnails for items returned in a user's own viewing history."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..config import settings
|
||||
|
||||
TOKEN_SECONDS = 3600
|
||||
MAX_IMAGE_BYTES = 1024 * 1024
|
||||
MAX_CACHE_BYTES = 16 * 1024 * 1024
|
||||
_cache = OrderedDict()
|
||||
_downloads = asyncio.Semaphore(6)
|
||||
|
||||
|
||||
def item_id(value):
|
||||
value = str(value or "").replace("-", "").lower()
|
||||
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
|
||||
|
||||
|
||||
def source(runtime):
|
||||
return hashlib.sha256(f"{runtime.jellyfin_base_url}|{runtime.jellyfin_api_key}".encode()).hexdigest()
|
||||
|
||||
|
||||
def signature(user, runtime, media_id, expires):
|
||||
message = f"insights-artwork\n{user['username']}\n{source(runtime)}\n{media_id}\n{expires}"
|
||||
return hmac.new(settings.jwt_secret.encode(), message.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def with_artwork(data, user, runtime):
|
||||
expires = int(time.time()) + TOKEN_SECONDS
|
||||
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):
|
||||
if not settings.jwt_secret or not re.fullmatch(r"[a-f0-9]{32}", media_id):
|
||||
raise HTTPException(404, "Artwork unavailable")
|
||||
if not re.fullmatch(r"[0-9]{1,12}\.[a-f0-9]{64}", token):
|
||||
raise HTTPException(403, "Artwork link is invalid or expired")
|
||||
try:
|
||||
expires_text, supplied = token.split(".", 1)
|
||||
expires = int(expires_text)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(403, "Artwork link is invalid or expired") from None
|
||||
now = int(time.time())
|
||||
if expires < now or expires > now + TOKEN_SECONDS or not hmac.compare_digest(supplied, signature(user, runtime, media_id, expires)):
|
||||
raise HTTPException(403, "Artwork link is invalid or expired")
|
||||
|
||||
|
||||
async def get_artwork(user, runtime, media_id, token):
|
||||
verify_artwork_token(user, runtime, media_id, token)
|
||||
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
|
||||
raise HTTPException(404, "Artwork unavailable")
|
||||
key = (source(runtime), media_id)
|
||||
async with _downloads:
|
||||
cached = _cache.get(key)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
_cache.move_to_end(key)
|
||||
return cached[1], cached[2]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
async with client.stream("GET", f"{runtime.jellyfin_base_url.rstrip('/')}/Items/{media_id}/Images/Primary",
|
||||
headers={"X-Emby-Token": runtime.jellyfin_api_key},
|
||||
params={"maxWidth": 120, "maxHeight": 180, "quality": 85, "format": "Webp"}) as response:
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
if content_type not in {"image/jpeg", "image/png", "image/webp"}:
|
||||
raise ValueError()
|
||||
content = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
content.extend(chunk)
|
||||
if len(content) > MAX_IMAGE_BYTES:
|
||||
raise ValueError()
|
||||
if not content:
|
||||
raise ValueError()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise HTTPException(404, "Artwork unavailable") from exc
|
||||
for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
|
||||
_cache.pop(expired, None)
|
||||
while _cache and (len(_cache) >= 128 or sum(len(value[1]) for value in _cache.values()) + len(content) > MAX_CACHE_BYTES):
|
||||
_cache.popitem(last=False)
|
||||
_cache[key] = (time.monotonic() + 600, bytes(content), content_type)
|
||||
return bytes(content), content_type
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,477 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from html import escape
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..config import settings as env_settings
|
||||
from ..db import (
|
||||
add_portal_item_activity,
|
||||
get_portal_item,
|
||||
get_user_by_username,
|
||||
list_portal_item_activity,
|
||||
list_portal_items,
|
||||
update_portal_item,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from .invite_email import resolve_user_delivery_email, send_generic_email
|
||||
from .snapshot import build_snapshot
|
||||
from .media_repair import evaluate_media_repair
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SYSTEM_USER = "Magent"
|
||||
_MEDIA_REPAIR_STARTED_EVENTS = {"replacement_started", "missing_search_started"}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> Optional[datetime]:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _metadata(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw = item.get("metadata_json")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
state = _metadata(item).get("resolutionConfirmation")
|
||||
return dict(state) if isinstance(state, dict) else {}
|
||||
|
||||
|
||||
def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str:
|
||||
metadata = _metadata(item)
|
||||
metadata["resolutionConfirmation"] = state
|
||||
return json.dumps(metadata, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
|
||||
def _interval_delta(value: int, unit: str) -> timedelta:
|
||||
safe_value = max(1, min(int(value), 365))
|
||||
normalized_unit = str(unit or "days").strip().lower()
|
||||
if normalized_unit == "weeks":
|
||||
return timedelta(weeks=safe_value)
|
||||
if normalized_unit == "months":
|
||||
return timedelta(days=30 * safe_value)
|
||||
return timedelta(days=safe_value)
|
||||
|
||||
|
||||
def _workflow_settings() -> tuple[int, int, str]:
|
||||
runtime = get_runtime_settings()
|
||||
attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10))
|
||||
interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365))
|
||||
interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower()
|
||||
if interval_unit not in {"days", "weeks", "months"}:
|
||||
interval_unit = "days"
|
||||
return attempts, interval_value, interval_unit
|
||||
|
||||
|
||||
def _app_url() -> str:
|
||||
runtime = get_runtime_settings()
|
||||
for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin):
|
||||
candidate = str(value or "").strip()
|
||||
if candidate:
|
||||
return candidate.rstrip("/")
|
||||
return f"http://localhost:{int(runtime.magent_application_port or 3000)}"
|
||||
|
||||
|
||||
def _issue_url(item_id: int) -> str:
|
||||
return f"{_app_url()}/portal/issues?item={item_id}"
|
||||
|
||||
|
||||
def _activity(
|
||||
item_id: int,
|
||||
event_type: str,
|
||||
message: str,
|
||||
*,
|
||||
actor_username: str = _SYSTEM_USER,
|
||||
actor_role: str = "system",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
add_portal_item_activity(
|
||||
item_id,
|
||||
event_type=event_type,
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
message=message,
|
||||
metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None,
|
||||
)
|
||||
|
||||
|
||||
def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw = entry.get("metadata_json")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
|
||||
activity = list_portal_item_activity(item_id, limit=500)
|
||||
for entry in reversed(activity):
|
||||
# A rejected repair must not be proposed again simply because the same
|
||||
# replacement file is still present. Wait for a NEW repair attempt.
|
||||
if str(entry.get("event_type") or "") == "resolution_rejected":
|
||||
return {}, activity
|
||||
if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
|
||||
continue
|
||||
tracking = _activity_metadata(entry).get("repairTracking")
|
||||
if isinstance(tracking, dict):
|
||||
return dict(tracking), activity
|
||||
return {}, activity
|
||||
|
||||
|
||||
async def _media_repair_evidence(tracking: Dict[str, Any]) -> Dict[str, Any]:
|
||||
snapshot = await build_snapshot(str(tracking.get("requestId") or ""))
|
||||
raw = snapshot.raw if isinstance(snapshot.raw, dict) else {}
|
||||
jellyfin = dict(raw.get("jellyfin") or {})
|
||||
jellyfin["found"] = jellyfin.get("catalogFound", jellyfin.get("found"))
|
||||
return await evaluate_media_repair(
|
||||
tracking, (raw.get("arr") or {}).get("item"), jellyfin,
|
||||
episodes=(raw.get("arr") or {}).get("episodes"),
|
||||
)
|
||||
|
||||
|
||||
def _close_issue(
|
||||
item: Dict[str, Any],
|
||||
*,
|
||||
reason: str,
|
||||
confirmed: bool,
|
||||
actor_username: str = _SYSTEM_USER,
|
||||
actor_role: str = "system",
|
||||
) -> Dict[str, Any]:
|
||||
now = _now().isoformat()
|
||||
state = issue_resolution_state(item)
|
||||
state.update(
|
||||
{
|
||||
"status": "confirmed" if confirmed else "auto_closed",
|
||||
"confirmedAt": now if confirmed else state.get("confirmedAt"),
|
||||
"closedAt": now,
|
||||
"nextContactAt": None,
|
||||
"closedReason": reason,
|
||||
}
|
||||
)
|
||||
updated = update_portal_item(
|
||||
int(item["id"]),
|
||||
status="closed",
|
||||
issue_resolved_at=now,
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue could not be closed")
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"resolution_confirmed" if confirmed else "issue_auto_closed",
|
||||
reason,
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
maximum, interval_value, interval_unit = _workflow_settings()
|
||||
state = issue_resolution_state(item)
|
||||
attempts = max(0, int(state.get("attemptsSent") or 0))
|
||||
if maximum <= 0:
|
||||
return _close_issue(
|
||||
item,
|
||||
reason="Issue closed automatically because reporter confirmation emails are disabled.",
|
||||
confirmed=False,
|
||||
)
|
||||
if attempts >= maximum:
|
||||
return _close_issue(
|
||||
item,
|
||||
reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.",
|
||||
confirmed=False,
|
||||
)
|
||||
|
||||
attempt_number = attempts + 1
|
||||
reporter = get_user_by_username(str(item.get("created_by_username") or ""))
|
||||
recipient = resolve_user_delivery_email(reporter)
|
||||
issue_url = f"{_app_url()}/issues/confirm/{int(item['id'])}"
|
||||
sent = False
|
||||
delivery_error: Optional[str] = None
|
||||
if recipient:
|
||||
subject = f"Ready to try again? Magent issue #{item['id']}"
|
||||
body_text = (
|
||||
"Your repair looks ready to test.\n\n"
|
||||
f"{item.get('title') or 'Your reported issue'}\n\n"
|
||||
"Please try the affected content in Jellyfin. Is it fixed?\n\n"
|
||||
f"YES — it works: {issue_url}#yes\n"
|
||||
f"NO — still broken: {issue_url}#no\n\n"
|
||||
"Confirm your answer in Magent. You may need to sign in first.\n"
|
||||
"Yes closes the report. No keeps it open for another look.\n\n"
|
||||
f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically."
|
||||
)
|
||||
body_html = (
|
||||
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
|
||||
'<table role="presentation" style="max-width:560px;width:100%;margin:auto;background:#202023;border:1px solid #45454d;border-radius:18px;"><tr><td style="padding:28px;">'
|
||||
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">MAGENT</p>'
|
||||
'<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
|
||||
'<p style="font-size:17px;line-height:1.6;color:#e4e4e7;">Your repair looks ready to test. Give the affected content a try, then let us know:</p>'
|
||||
f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
|
||||
'<h2 style="font-size:26px;color:#fff;margin:24px 0 16px;">Is it fixed?</h2>'
|
||||
f'<a href="{escape(issue_url)}#yes" style="display:block;text-align:center;padding:20px;margin-bottom:12px;border-radius:12px;background:#b4f4d2;color:#10261b;text-decoration:none;font-size:24px;font-weight:bold;">YES — it works</a>'
|
||||
f'<a href="{escape(issue_url)}#no" style="display:block;text-align:center;padding:20px;border-radius:12px;background:#ffc1c5;color:#391318;text-decoration:none;font-size:24px;font-weight:bold;">NO — still broken</a>'
|
||||
'<p style="font-size:14px;line-height:1.6;color:#dedee3;">Confirm your answer in Magent. You may need to sign in first.<br>Yes closes the report. No keeps it open for another look.</p>'
|
||||
f'<p style="font-size:12px;line-height:1.6;color:#b9b9c3;">Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])}<br>If we do not hear back after the reminder period, this report will close automatically.</p>'
|
||||
'</td></tr></table></div>'
|
||||
)
|
||||
try:
|
||||
await send_generic_email(
|
||||
recipient_email=recipient,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
)
|
||||
sent = True
|
||||
except Exception as exc:
|
||||
delivery_error = str(exc)
|
||||
logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
|
||||
else:
|
||||
delivery_error = "No email address is stored for the reporter."
|
||||
|
||||
now = _now()
|
||||
state.update(
|
||||
{
|
||||
"status": "awaiting_confirmation",
|
||||
"attemptsSent": attempt_number,
|
||||
"maximumAttempts": maximum,
|
||||
"lastContactAt": now.isoformat(),
|
||||
"nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
|
||||
"intervalValue": interval_value,
|
||||
"intervalUnit": interval_unit,
|
||||
"lastDeliverySucceeded": sent,
|
||||
"lastDeliveryError": delivery_error,
|
||||
}
|
||||
)
|
||||
updated = update_portal_item(
|
||||
int(item["id"]),
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue confirmation schedule could not be saved")
|
||||
if sent:
|
||||
message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
|
||||
else:
|
||||
message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"confirmation_email_sent" if sent else "confirmation_email_failed",
|
||||
message,
|
||||
metadata={
|
||||
"attempt": attempt_number,
|
||||
"maximum": maximum,
|
||||
"nextContactAt": state["nextContactAt"],
|
||||
"deliveryError": delivery_error,
|
||||
},
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def begin_issue_confirmation(
|
||||
item_id: int,
|
||||
*,
|
||||
actor_username: str,
|
||||
actor_role: str,
|
||||
) -> Dict[str, Any]:
|
||||
item = get_portal_item(item_id)
|
||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
||||
raise ValueError("Issue not found")
|
||||
now = _now().isoformat()
|
||||
maximum, interval_value, interval_unit = _workflow_settings()
|
||||
state = {
|
||||
"status": "awaiting_confirmation",
|
||||
"startedAt": now,
|
||||
"attemptsSent": 0,
|
||||
"maximumAttempts": maximum,
|
||||
"lastContactAt": None,
|
||||
"nextContactAt": now,
|
||||
"intervalValue": interval_value,
|
||||
"intervalUnit": interval_unit,
|
||||
"confirmedAt": None,
|
||||
"closedAt": None,
|
||||
}
|
||||
updated = update_portal_item(
|
||||
item_id,
|
||||
status="awaiting_confirmation",
|
||||
issue_resolved_at=None,
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue confirmation workflow could not be started")
|
||||
_activity(
|
||||
item_id,
|
||||
"resolution_proposed",
|
||||
"The issue was marked fixed and sent to the reporter for confirmation.",
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
|
||||
)
|
||||
return await _contact_reporter(updated)
|
||||
|
||||
|
||||
def respond_to_issue_confirmation(
|
||||
item_id: int,
|
||||
*,
|
||||
resolved: bool,
|
||||
actor_username: str,
|
||||
actor_role: str,
|
||||
) -> Dict[str, Any]:
|
||||
item = get_portal_item(item_id)
|
||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
||||
raise ValueError("Issue not found")
|
||||
if str(item.get("status") or "").lower() != "awaiting_confirmation":
|
||||
raise ValueError("This issue is not waiting for resolution confirmation")
|
||||
if resolved:
|
||||
return _close_issue(
|
||||
item,
|
||||
reason="The reporter confirmed that the issue is fixed.",
|
||||
confirmed=True,
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
)
|
||||
|
||||
now = _now().isoformat()
|
||||
state = issue_resolution_state(item)
|
||||
state.update(
|
||||
{
|
||||
"status": "reported_still_broken",
|
||||
"reporterResponseAt": now,
|
||||
"nextContactAt": None,
|
||||
"closedAt": None,
|
||||
}
|
||||
)
|
||||
updated = update_portal_item(
|
||||
item_id,
|
||||
status="in_progress",
|
||||
issue_resolved_at=None,
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue could not be reopened")
|
||||
_activity(
|
||||
item_id,
|
||||
"resolution_rejected",
|
||||
"The reporter said the issue is still happening. The issue was returned to In progress.",
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def process_active_media_repairs() -> Dict[str, int]:
|
||||
items = list_portal_items(kind="issue", status="in_progress", limit=500)
|
||||
result = {"checked": 0, "waiting": 0, "completed": 0, "failed": 0}
|
||||
for item in items:
|
||||
tracking, activity = _repair_tracking(int(item["id"]))
|
||||
if not tracking:
|
||||
continue
|
||||
result["checked"] += 1
|
||||
try:
|
||||
evidence = await _media_repair_evidence(tracking)
|
||||
if evidence.get("complete"):
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"repair_verified",
|
||||
str(evidence.get("message") or "Magent verified the repaired media in Jellyfin."),
|
||||
metadata={
|
||||
"requestId": tracking.get("requestId"),
|
||||
"actionId": tracking.get("actionId"),
|
||||
},
|
||||
)
|
||||
await begin_issue_confirmation(
|
||||
int(item["id"]),
|
||||
actor_username=_SYSTEM_USER,
|
||||
actor_role="system",
|
||||
)
|
||||
result["completed"] += 1
|
||||
continue
|
||||
|
||||
result["waiting"] += 1
|
||||
if evidence.get("phase") == "indexing" and not any(
|
||||
str(entry.get("event_type") or "") == "repair_imported"
|
||||
for entry in activity
|
||||
):
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"repair_imported",
|
||||
str(evidence.get("message") or "The repaired file was imported and is waiting for Jellyfin."),
|
||||
metadata={
|
||||
"requestId": tracking.get("requestId"),
|
||||
"actionId": tracking.get("actionId"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
result["failed"] += 1
|
||||
logger.exception("automatic media repair check failed item_id=%s", item.get("id"))
|
||||
return result
|
||||
|
||||
|
||||
async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
|
||||
current = (now or _now()).astimezone(timezone.utc)
|
||||
items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
|
||||
result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
|
||||
maximum, _, _ = _workflow_settings()
|
||||
for item in items:
|
||||
state = issue_resolution_state(item)
|
||||
due_at = _parse_datetime(state.get("nextContactAt"))
|
||||
if due_at and due_at > current:
|
||||
continue
|
||||
try:
|
||||
attempts = max(0, int(state.get("attemptsSent") or 0))
|
||||
if maximum <= 0 or attempts >= maximum:
|
||||
_close_issue(
|
||||
item,
|
||||
reason=(
|
||||
"Issue closed automatically because reporter confirmation emails are disabled."
|
||||
if maximum <= 0
|
||||
else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
|
||||
),
|
||||
confirmed=False,
|
||||
)
|
||||
result["closed"] += 1
|
||||
else:
|
||||
await _contact_reporter(item)
|
||||
result["contacted"] += 1
|
||||
except Exception:
|
||||
result["failed"] += 1
|
||||
logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
|
||||
return result
|
||||
|
||||
|
||||
async def run_issue_confirmation_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
repair_result = await process_active_media_repairs()
|
||||
if repair_result["completed"] or repair_result["failed"]:
|
||||
logger.info("automatic media repair sweep complete result=%s", repair_result)
|
||||
result = await process_due_issue_confirmations()
|
||||
if result["contacted"] or result["closed"] or result["failed"]:
|
||||
logger.info("issue confirmation sweep complete result=%s", result)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("issue confirmation sweep failed")
|
||||
await asyncio.sleep(60)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Stable Jellyfin identities for private, user-scoped integrations."""
|
||||
|
||||
import hashlib
|
||||
from contextlib import closing
|
||||
|
||||
from .. import db
|
||||
|
||||
|
||||
def source_key(base_url: str | None) -> str:
|
||||
return hashlib.sha256(str(base_url or "").strip().rstrip("/").encode()).hexdigest()
|
||||
|
||||
|
||||
def linked_user_id(username: str, base_url: str | None) -> str | None:
|
||||
user = db.get_user_by_username(username)
|
||||
if not user or not base_url:
|
||||
return None
|
||||
with closing(db._connect()) as conn, conn:
|
||||
row = conn.execute(
|
||||
"SELECT jellyfin_user_id FROM jellyfin_user_links WHERE source = ? AND local_user_id = ?",
|
||||
(source_key(base_url), user["id"]),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> None:
|
||||
"""Use only verified login or canonical Jellyfin user sync, never playback names."""
|
||||
user = db.get_user_by_username(username)
|
||||
if not user or not jellyfin_user_id or not base_url:
|
||||
return
|
||||
with closing(db._connect()) as conn, conn:
|
||||
if conn.execute("SELECT 1 FROM user_identity_confirmations WHERE local_user_id = ?", (user["id"],)).fetchone():
|
||||
# Reviewed identities are updated only through the admin confirmation workflow.
|
||||
return
|
||||
# A renamed or re-created account must not silently take over an existing identity.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
|
||||
(source_key(base_url), user["id"], str(jellyfin_user_id)),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -1,14 +1,24 @@
|
||||
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
|
||||
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..db import create_user_if_missing, set_user_jellyseerr_id
|
||||
from ..db import (
|
||||
create_user_if_missing,
|
||||
get_user_by_username,
|
||||
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,
|
||||
get_cached_jellyseerr_users,
|
||||
match_jellyseerr_user_id,
|
||||
save_jellyfin_users_cache,
|
||||
)
|
||||
|
||||
@@ -24,27 +34,55 @@ async def sync_jellyfin_users() -> int:
|
||||
if not isinstance(users, list):
|
||||
return 0
|
||||
save_jellyfin_users_cache(users)
|
||||
# Jellyfin is the canonical source for local user objects; Seerr IDs are
|
||||
# matched as enrichment when possible.
|
||||
jellyseerr_users = get_cached_jellyseerr_users()
|
||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
||||
imported = 0
|
||||
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
|
||||
created = create_user_if_missing(
|
||||
name,
|
||||
"jellyfin-user",
|
||||
role="user",
|
||||
auth_provider="jellyfin",
|
||||
jellyseerr_user_id=matched_id,
|
||||
)
|
||||
if created:
|
||||
imported += 1
|
||||
elif matched_id is not None:
|
||||
set_user_jellyseerr_id(name, matched_id)
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
|
||||
def current_cycle_torrents(torrents: Any, cycle: str | None) -> list[Dict[str, Any]]:
|
||||
"""Old seeding jobs are not proof of a replacement download.
|
||||
|
||||
A same-hash retry is valid when it is downloading again or was added anew.
|
||||
Without a completion/add timestamp, a completed legacy job cannot prove that.
|
||||
"""
|
||||
rows = [item for item in torrents if isinstance(item, dict)] if isinstance(torrents, list) else []
|
||||
if not cycle:
|
||||
return rows
|
||||
cutoff = datetime.fromisoformat(cycle).timestamp()
|
||||
def belongs(item: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
progress = float(item.get("progress", 0))
|
||||
completed = float(item.get("completion_on") or 0)
|
||||
added = float(item.get("added_on") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return progress < 1 or max(completed, added) >= cutoff
|
||||
return [item for item in rows if belongs(item)]
|
||||
|
||||
|
||||
def _positive_ints(value: Any) -> list[int]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [
|
||||
int(item)
|
||||
for item in value
|
||||
if isinstance(item, int) and not isinstance(item, bool) and item > 0
|
||||
]
|
||||
|
||||
|
||||
def _media_signature(item: Any) -> Dict[str, str]:
|
||||
if not isinstance(item, dict):
|
||||
return {}
|
||||
result: Dict[str, str] = {}
|
||||
for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, (dict, list)):
|
||||
result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
elif value is not None and str(value).strip():
|
||||
result[key] = str(value).strip()
|
||||
return result
|
||||
|
||||
|
||||
def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool:
|
||||
previous = _media_signature(baseline)
|
||||
if not previous:
|
||||
return True
|
||||
return any(current.get(key) and current.get(key) != value for key, value in previous.items())
|
||||
|
||||
|
||||
async def evaluate_media_repair(
|
||||
tracking: Dict[str, Any], arr_item: Any, jellyfin: Dict[str, Any],
|
||||
*, episodes: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
request_id = str(tracking.get("requestId") or "").strip()
|
||||
action_id = str(tracking.get("actionId") or "").strip()
|
||||
media_type = str(tracking.get("mediaType") or "").strip().lower()
|
||||
collector_id = tracking.get("collectorId")
|
||||
if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int):
|
||||
return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."}
|
||||
|
||||
jellyfin_item = jellyfin.get("item")
|
||||
original_file_ids = set(_positive_ints(tracking.get("originalFileIds")))
|
||||
baselines = tracking.get("jellyfinBaseline")
|
||||
baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else []
|
||||
found_at_start = tracking.get("jellyfinFoundAtStart") is True
|
||||
|
||||
if not isinstance(arr_item, dict) or arr_item.get("id") != collector_id:
|
||||
return {"complete": False, "phase": "collecting", "message": "Waiting for the correct collector record."}
|
||||
|
||||
if media_type == "movie":
|
||||
movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None
|
||||
current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None
|
||||
imported = arr_item.get("hasFile") is not False and isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids
|
||||
if not imported:
|
||||
return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."}
|
||||
if not jellyfin.get("found") or not isinstance(jellyfin_item, dict):
|
||||
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."}
|
||||
current_signature = _media_signature(jellyfin_item)
|
||||
if action_id == "replace_media" and found_at_start:
|
||||
if not baselines or not _signature_changed(current_signature, baselines[0]):
|
||||
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."}
|
||||
return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."}
|
||||
|
||||
target_rows = tracking.get("episodes")
|
||||
targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else []
|
||||
target_ids = {
|
||||
int(item["id"])
|
||||
for item in targets
|
||||
if isinstance(item.get("id"), int) and int(item["id"]) > 0
|
||||
}
|
||||
target_pairs = {
|
||||
(int(item["seasonNumber"]), int(item["episodeNumber"]))
|
||||
for item in targets
|
||||
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
|
||||
}
|
||||
if not target_ids or not target_pairs:
|
||||
return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."}
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
if episodes is None:
|
||||
episodes = await sonarr.get_episodes(collector_id)
|
||||
episode_map = {
|
||||
int(item["id"]): item
|
||||
for item in episodes
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int)
|
||||
} if isinstance(episodes, list) else {}
|
||||
imported = all(
|
||||
episode_id in episode_map
|
||||
and episode_map[episode_id].get("hasFile") is not False
|
||||
and (
|
||||
episode_map[episode_id].get("hasFile") is True
|
||||
or (
|
||||
isinstance(episode_map[episode_id].get("episodeFileId"), int)
|
||||
and episode_map[episode_id]["episodeFileId"] > 0
|
||||
)
|
||||
)
|
||||
and episode_map[episode_id].get("episodeFileId") not in original_file_ids
|
||||
for episode_id in target_ids
|
||||
)
|
||||
if not imported:
|
||||
return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."}
|
||||
|
||||
jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
|
||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured():
|
||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."}
|
||||
jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id))
|
||||
current_by_pair = {
|
||||
(int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item
|
||||
for item in jellyfin_episodes
|
||||
if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int)
|
||||
}
|
||||
if not all(pair in current_by_pair for pair in target_pairs):
|
||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."}
|
||||
if action_id == "replace_media" and found_at_start:
|
||||
baseline_by_pair = {
|
||||
(int(item["seasonNumber"]), int(item["episodeNumber"])): item
|
||||
for item in baselines
|
||||
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
|
||||
}
|
||||
if any(pair not in baseline_by_pair for pair in target_pairs):
|
||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."}
|
||||
if not all(
|
||||
_signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair])
|
||||
for pair in target_pairs
|
||||
):
|
||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."}
|
||||
return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Personal calendar-month reports built from retained Jellystat history."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..clients.jellystat import JellystatClient
|
||||
from ..runtime import get_runtime_settings
|
||||
from .insights import request_summary, resolve_identity, summarize
|
||||
from .insights_artwork import with_artwork
|
||||
|
||||
_cache: dict[tuple, tuple[float, dict]] = {}
|
||||
CACHE_SECONDS = 60
|
||||
MONTH_COUNT = 24
|
||||
|
||||
|
||||
def shift_month(value: datetime, offset: int) -> datetime:
|
||||
year, month = divmod(value.year * 12 + value.month - 1 + offset, 12)
|
||||
return datetime(year, month + 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def month_periods(month: str | None, now: datetime) -> dict:
|
||||
now = now.astimezone(timezone.utc)
|
||||
this_month = shift_month(now, 0)
|
||||
available = [shift_month(this_month, -offset).strftime("%Y-%m") for offset in range(MONTH_COUNT)]
|
||||
selected = month if month is not None else available[1]
|
||||
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", selected) or selected not in available:
|
||||
raise ValueError("Choose the current month or one of the previous 23 months.")
|
||||
start = datetime.strptime(selected, "%Y-%m").replace(tzinfo=timezone.utc)
|
||||
calendar_end = shift_month(start, 1)
|
||||
end = min(calendar_end, now)
|
||||
previous_start = shift_month(start, -1)
|
||||
partial = end < calendar_end
|
||||
previous_end = min(previous_start + (end - start), start) if partial else start
|
||||
return {"month": selected, "available_months": available, "timezone": "UTC",
|
||||
"period_start": start.isoformat(), "period_end": end.isoformat(),
|
||||
"is_partial": partial, "comparison_month": previous_start.strftime("%Y-%m"),
|
||||
"comparison_start": previous_start.isoformat(), "comparison_end": previous_end.isoformat(),
|
||||
"comparison_capped": partial and previous_start + (end - start) > start}
|
||||
|
||||
|
||||
def change(current: float, previous: float) -> dict:
|
||||
difference = round(current - previous, 1)
|
||||
percent = round(difference / previous * 100, 1) if previous else 0.0 if not current else None
|
||||
return {"current": current, "previous": previous, "difference": difference, "percent": percent}
|
||||
|
||||
|
||||
async def get_monthly_report(user: dict, month: str | None = None) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
periods = month_periods(month, now)
|
||||
runtime = await asyncio.to_thread(get_runtime_settings)
|
||||
base = {**periods, "source": "Jellystat", "is_admin": user.get("role") == "admin", "summary": None}
|
||||
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
if not client.configured():
|
||||
return {**base, "state": "not_configured"}
|
||||
identity = await resolve_identity(user, runtime)
|
||||
if not identity:
|
||||
return {**base, "state": "unlinked"}
|
||||
# Cache playback only. Request ownership and request statuses are read afresh.
|
||||
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
|
||||
runtime.jellyfin_base_url, identity, periods["month"], now.strftime("%Y-%m"))
|
||||
cached = _cache.get(key)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
data = cached[1]
|
||||
else:
|
||||
history, libraries = await client.get_user_history(identity,
|
||||
datetime.fromisoformat(periods["comparison_start"]), datetime.fromisoformat(periods["period_end"]))
|
||||
current = summarize(history, libraries, datetime.fromisoformat(periods["period_start"]),
|
||||
datetime.fromisoformat(periods["period_end"]), end_exclusive=True)
|
||||
previous = summarize(history, libraries, datetime.fromisoformat(periods["comparison_start"]),
|
||||
datetime.fromisoformat(periods["comparison_end"]), end_exclusive=True)
|
||||
data = {**periods, **current, "previous_summary": previous["summary"], "updated_at": now.isoformat()}
|
||||
for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
|
||||
_cache.pop(expired, None)
|
||||
if len(_cache) >= 128:
|
||||
_cache.pop(next(iter(_cache)))
|
||||
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
|
||||
requests, previous_requests = await asyncio.gather(
|
||||
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["period_start"]),
|
||||
datetime.fromisoformat(data["period_end"]), end_exclusive=True),
|
||||
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["comparison_start"]),
|
||||
datetime.fromisoformat(data["comparison_end"]), end_exclusive=True))
|
||||
changes = {name: change(data["summary"][name], data["previous_summary"][name])
|
||||
for name in ("minutes", "movies", "episodes", "plays", "active_days", "longest_streak")}
|
||||
changes["requests"] = change(requests["total"], previous_requests["total"])
|
||||
return {**base, **with_artwork(data, user, runtime), "state": "ready", "requests": requests,
|
||||
"previous_requests": {name: value for name, value in previous_requests.items() if name != "recent"},
|
||||
"changes": changes}
|
||||
|
||||
|
||||
def report_csv(report: dict) -> str:
|
||||
"""Export normalized data only; protect text cells from spreadsheet formulas."""
|
||||
output = io.StringIO(newline="")
|
||||
writer = csv.writer(output)
|
||||
|
||||
def row(*cells):
|
||||
safe = []
|
||||
for cell in cells:
|
||||
if isinstance(cell, str) and re.match(r"^[\s\ufeff]*[=+\-@]", cell):
|
||||
cell = "'" + cell
|
||||
safe.append(cell)
|
||||
writer.writerow(safe)
|
||||
|
||||
row("Magent monthly viewing report", report["month"])
|
||||
row("Timezone", "UTC")
|
||||
row("Period start (inclusive)", report["period_start"])
|
||||
row("Period end (exclusive)", report["period_end"])
|
||||
row("Report period", "Month to date" if report["is_partial"] else "Complete calendar month")
|
||||
row("Comparison start (inclusive)", report["comparison_start"])
|
||||
row("Comparison end (exclusive)", report["comparison_end"])
|
||||
row("Generated at", report["updated_at"])
|
||||
row("Data coverage", "Retained Jellystat history and requests available in Magent; request statuses are current.")
|
||||
row()
|
||||
row("Metric", "This period", "Previous period", "Difference", "Change (%)")
|
||||
labels = {"minutes": "Minutes watched", "movies": "Distinct movies played", "episodes": "Distinct episodes played",
|
||||
"plays": "Plays", "active_days": "Active days", "longest_streak": "Longest streak (days)", "requests": "Requests made"}
|
||||
for name, label in labels.items():
|
||||
value = report["changes"][name]
|
||||
row(label, value["current"], value["previous"], value["difference"], value["percent"])
|
||||
row()
|
||||
row("Date (UTC)", "Minutes watched")
|
||||
for day in report["daily"]:
|
||||
row(day["date"], day["minutes"])
|
||||
row()
|
||||
row("Most watched title", "Media type", "Minutes", "Plays")
|
||||
for title in report["top_titles"]:
|
||||
row(title["title"], title["type"], title["minutes"], title["plays"])
|
||||
for field, label in (("clients", "Player"), ("methods", "Streaming method")):
|
||||
row()
|
||||
row(label, "Playback minutes")
|
||||
for entry in report[field]:
|
||||
row(entry["name"], entry["minutes"])
|
||||
row()
|
||||
row("Transcoding", "Playback minutes")
|
||||
for field, label in (("hardware_video_minutes", "GPU-assisted video"), ("audio_minutes", "Audio transcoding"),
|
||||
("video_minutes", "Video transcoding"), ("software_video_minutes", "Software video"),
|
||||
("unknown_hardware_minutes", "Video hardware not recorded"),
|
||||
("unknown_video_minutes", "Video details not recorded"), ("unknown_audio_minutes", "Audio details not recorded")):
|
||||
row(label, report["transcoding"][field])
|
||||
row("GPU busy time", "Not recorded; audio/video playback durations can overlap.")
|
||||
row()
|
||||
row("Requests", "Count")
|
||||
for field, label in (("movies", "Movies"), ("tv", "TV shows"), ("pending", "Pending"), ("approved", "Approved"), ("declined", "Declined")):
|
||||
row(label, report["requests"][field])
|
||||
return "\ufeff" + output.getvalue()
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Bounded Jellyfin arrival snapshots, recipient access checks and email-safe posters."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
|
||||
from .insights_artwork import item_id
|
||||
from .jellyfin_identity import source_key
|
||||
|
||||
MAX_ITEMS = 5000
|
||||
PAGE_SIZE = 200
|
||||
MAX_TITLES = 60
|
||||
_posters = OrderedDict()
|
||||
_poster_lock = asyncio.Semaphore(4)
|
||||
|
||||
|
||||
class CatalogError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def date(value) -> datetime | None:
|
||||
try:
|
||||
result = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
|
||||
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
async def get_json(client, runtime, path, params=None):
|
||||
try:
|
||||
response = await client.get(runtime.jellyfin_base_url.rstrip('/') + path,
|
||||
headers={'X-Emby-Token': runtime.jellyfin_api_key}, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise CatalogError('Jellyfin is temporarily unavailable. Please try again.') from exc
|
||||
|
||||
|
||||
def group_arrivals(items: list[dict], start: datetime, end: datetime) -> list[dict]:
|
||||
groups = {}
|
||||
seen = set()
|
||||
for row in items:
|
||||
identity = item_id(row.get('Id'))
|
||||
added = date(row.get('DateCreated'))
|
||||
if (not identity or identity in seen or not added or not start <= added < end
|
||||
or row.get('LocationType') == 'Virtual' or row.get('IsPlaceHolder')):
|
||||
continue
|
||||
kind = row.get('Type')
|
||||
if kind not in {'Movie', 'Episode'}:
|
||||
continue
|
||||
parent = item_id(row.get('SeriesId')) if kind == 'Episode' else identity
|
||||
if not parent:
|
||||
continue
|
||||
seen.add(identity)
|
||||
title = str((row.get('SeriesName') if kind == 'Episode' else row.get('Name')) or '').strip()
|
||||
if not title:
|
||||
continue
|
||||
entry = groups.setdefault(parent, {'id': parent, 'type': 'series' if kind == 'Episode' else 'movie',
|
||||
'title': title[:250], 'year': row.get('ProductionYear') if kind == 'Movie' else None,
|
||||
'overview': str(row.get('Overview') or '')[:500] if kind == 'Movie' else '',
|
||||
'added_at': added.isoformat(), 'has_artwork': False, 'items': [], 'selected': False, 'featured': False})
|
||||
entry['added_at'] = max(entry['added_at'], added.isoformat())
|
||||
entry['has_artwork'] |= bool(row.get('SeriesPrimaryImageTag') if kind == 'Episode' else (row.get('ImageTags') or {}).get('Primary'))
|
||||
entry['items'].append({'id': identity, 'season': row.get('ParentIndexNumber') if kind == 'Episode' else None,
|
||||
'number': row.get('IndexNumber') if kind == 'Episode' else None})
|
||||
return sorted(groups.values(), key=lambda row: (row['added_at'], row['id']), reverse=True)
|
||||
|
||||
|
||||
async def collect(runtime, start: datetime, end: datetime, limit: int = 12) -> dict:
|
||||
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
|
||||
raise CatalogError('Connect Jellyfin before collecting new arrivals.')
|
||||
rows, seen = [], set()
|
||||
exhausted = False
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
info = await get_json(client, runtime, '/System/Info')
|
||||
server_id = item_id(info.get('Id')) if isinstance(info, dict) else None
|
||||
if not server_id:
|
||||
raise CatalogError('Jellyfin did not return its server identity.')
|
||||
for offset in range(0, MAX_ITEMS, PAGE_SIZE):
|
||||
payload = await get_json(client, runtime, '/Items', {'Recursive': 'true', 'IncludeItemTypes': 'Movie,Episode',
|
||||
'SortBy': 'DateCreated,SortName', 'SortOrder': 'Descending', 'Fields': 'DateCreated,Overview',
|
||||
'EnableUserData': 'false', 'IsMissing': 'false', 'IsPlaceHolder': 'false', 'Limit': PAGE_SIZE, 'StartIndex': offset})
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get('Items'), list):
|
||||
raise CatalogError('Jellyfin returned an incomplete arrival list.')
|
||||
page = payload['Items']
|
||||
total = payload.get('TotalRecordCount')
|
||||
if not isinstance(total, int) or total < offset + len(page):
|
||||
raise CatalogError('Jellyfin returned an incomplete arrival count.')
|
||||
for row in page:
|
||||
if not isinstance(row, dict) or not item_id(row.get('Id')) or not date(row.get('DateCreated')):
|
||||
raise CatalogError('Jellyfin returned an arrival without a valid identity or added date.')
|
||||
identity = item_id(row['Id'])
|
||||
if identity in seen:
|
||||
raise CatalogError('The library changed during collection. Refresh arrivals to try again.')
|
||||
seen.add(identity)
|
||||
if rows and date(row['DateCreated']) > date(rows[-1]['DateCreated']):
|
||||
raise CatalogError('The library changed during collection. Refresh arrivals to try again.')
|
||||
rows.append(row)
|
||||
if (not page or len(page) < PAGE_SIZE) and offset + len(page) < total:
|
||||
raise CatalogError('Jellyfin returned an incomplete arrival page.')
|
||||
if not page or any(date(row['DateCreated']) < start for row in page) or offset + len(page) >= total:
|
||||
exhausted = True
|
||||
break
|
||||
if not exhausted:
|
||||
raise CatalogError('More than 5,000 recent items were found. Choose a shorter arrival period; no partial edition was created.')
|
||||
titles = group_arrivals(rows, start, end)
|
||||
total = len(titles)
|
||||
titles = titles[:MAX_TITLES]
|
||||
for index, title in enumerate(titles):
|
||||
title['selected'] = index < limit
|
||||
return {'source': source_key(runtime.jellyfin_base_url), 'server_id': server_id,
|
||||
'period_start': start.isoformat(), 'period_end': end.isoformat(), 'total_titles': total, 'titles': titles}
|
||||
|
||||
|
||||
async def for_recipient(runtime, content: dict, jellyfin_id: str) -> dict:
|
||||
"""Scope every ID lookup to a view Jellyfin permits this user to browse.
|
||||
|
||||
Jellyfin 10.11's AddUserToQuery skips its default library filter when ItemIds
|
||||
is present. UserId alone is insufficient; ParentId supplies the allowed scope.
|
||||
"""
|
||||
if not item_id(jellyfin_id):
|
||||
raise CatalogError('The recipient does not have a valid Jellyfin identity.')
|
||||
selected = [entry for entry in content['titles'] if entry['selected']]
|
||||
ids = sorted({identity for entry in selected for identity in [entry['id'], *(item['id'] for item in entry['items'])]})
|
||||
allowed = set()
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
info = await get_json(client, runtime, '/System/Info')
|
||||
if not isinstance(info, dict) or source_key(runtime.jellyfin_base_url) != content['source'] or item_id(info.get('Id')) != content['server_id']:
|
||||
raise CatalogError('The Jellyfin server changed. Create a new edition for the current library.')
|
||||
user = await get_json(client, runtime, '/Users/' + jellyfin_id)
|
||||
if not isinstance(user, dict) or item_id(user.get('Id')) != item_id(jellyfin_id) or not isinstance(user.get('Policy'), dict):
|
||||
raise CatalogError('Could not verify the recipient’s Jellyfin account.')
|
||||
if user['Policy'].get('IsDisabled') or user['Policy'].get('EnableMediaPlayback') is False:
|
||||
return {**content, 'titles': [], 'recipient_disabled': True}
|
||||
views = await get_json(client, runtime, '/UserViews', {'UserId': jellyfin_id, 'IncludeHidden': 'true', 'IncludeExternalContent': 'false'})
|
||||
if not isinstance(views, dict) or not isinstance(views.get('Items'), list) or len(views['Items']) > 32:
|
||||
raise CatalogError('Could not check the recipient’s library access.')
|
||||
for view in views['Items']:
|
||||
parent = item_id(view.get('Id')) if isinstance(view, dict) else None
|
||||
if not parent:
|
||||
raise CatalogError('Jellyfin returned a library without a valid identity.')
|
||||
for offset in range(0, len(ids), 100):
|
||||
chunk = ids[offset:offset + 100]
|
||||
payload = await get_json(client, runtime, '/Items', {'UserId': jellyfin_id, 'ParentId': parent, 'Ids': ','.join(chunk),
|
||||
'Recursive': 'true', 'Limit': len(chunk), 'EnableUserData': 'false', 'EnableImages': 'false',
|
||||
'IsMissing': 'false', 'IsPlaceHolder': 'false'})
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get('Items'), list):
|
||||
raise CatalogError('Could not check the recipient’s library access.')
|
||||
allowed.update(item_id(item.get('Id')) for item in payload['Items'] if isinstance(item, dict))
|
||||
titles = []
|
||||
for entry in selected:
|
||||
accessible = [item for item in entry['items'] if item['id'] in allowed]
|
||||
if entry['id'] in allowed and accessible:
|
||||
titles.append({**entry, 'items': accessible})
|
||||
return {**content, 'titles': titles}
|
||||
|
||||
|
||||
async def poster(runtime, identity: str) -> bytes | None:
|
||||
if not item_id(identity):
|
||||
return None
|
||||
key = (source_key(runtime.jellyfin_base_url), hashlib.sha256(runtime.jellyfin_api_key.encode()).hexdigest(), identity)
|
||||
async with _poster_lock:
|
||||
cached = _posters.get(key)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
_posters.move_to_end(key)
|
||||
return cached[1]
|
||||
result = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with client.stream('GET', runtime.jellyfin_base_url.rstrip('/') + f'/Items/{identity}/Images/Primary',
|
||||
headers={'X-Emby-Token': runtime.jellyfin_api_key}, params={'maxWidth': 160, 'maxHeight': 240, 'quality': 82, 'format': 'Jpg'}) as response:
|
||||
response.raise_for_status()
|
||||
data = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
data.extend(chunk)
|
||||
if len(data) > 512 * 1024:
|
||||
raise ValueError('Poster too large')
|
||||
with Image.open(io.BytesIO(data)) as image:
|
||||
if image.width * image.height > 4_000_000:
|
||||
raise ValueError('Poster dimensions too large')
|
||||
image.thumbnail((160, 240))
|
||||
target = io.BytesIO()
|
||||
image.convert('RGB').save(target, format='JPEG', quality=82)
|
||||
result = target.getvalue()
|
||||
except (httpx.HTTPError, ValueError, OSError, Image.DecompressionBombError):
|
||||
pass
|
||||
_posters[key] = (time.monotonic() + (1800 if result else 60), result)
|
||||
while len(_posters) > 128:
|
||||
_posters.popitem(last=False)
|
||||
return result
|
||||
|
||||
|
||||
async def posters(runtime, content: dict) -> dict:
|
||||
titles = [entry for entry in content['titles'] if entry['selected'] and entry['has_artwork']]
|
||||
results = await asyncio.gather(*(poster(runtime, entry['id']) for entry in titles))
|
||||
return {entry['id']: data for entry, data in zip(titles, results) if data}
|
||||
@@ -0,0 +1,74 @@
|
||||
import base64
|
||||
import html
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .recap_email import document
|
||||
|
||||
|
||||
def description(entry):
|
||||
if entry['type'] == 'movie':
|
||||
return f"Movie · {entry['year']}" if entry.get('year') else 'Movie'
|
||||
seasons = sorted({item['season'] for item in entry['items'] if isinstance(item.get('season'), int)})
|
||||
count = len(entry['items'])
|
||||
labels = ', '.join('Specials' if value == 0 else str(value) for value in seasons[:8])
|
||||
suffix = f" · {'Season' if len(seasons) == 1 else 'Seasons'} {labels}" if labels else ''
|
||||
return f"{count} new {'episode' if count == 1 else 'episodes'}{suffix}"
|
||||
|
||||
|
||||
def render_confirmation(username, url):
|
||||
intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from your media library."
|
||||
return {'subject': 'Confirm your Magent newsletter subscription',
|
||||
'body_text': f'{intro}\n\nConfirm newsletter subscription: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email.',
|
||||
'body_html': document(title='Your next watch starts here.', intro=intro,
|
||||
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">A weekly look at new movies and TV updates, with posters and links to watch.</p>',
|
||||
action='Confirm newsletter subscription', url=url, kicker='NEW IN YOUR LIBRARY',
|
||||
footer='This link expires in 24 hours. If you did not request this, ignore this email.')}
|
||||
|
||||
|
||||
def render(content, images, public_url, playback_url, unsubscribe_url, *, preview=False, test=False):
|
||||
esc = html.escape
|
||||
titles = [entry for entry in content['titles'] if entry['selected']]
|
||||
body, lines, attachments = [], [], []
|
||||
intro = str(content.get('intro') or '').strip()
|
||||
if intro:
|
||||
body.append(f'<p style="font-size:15px;line-height:1.8;color:#e5e1e4;overflow-wrap:anywhere">{esc(intro).replace(chr(10), "<br>")}</p>')
|
||||
lines += [intro, '']
|
||||
sections = [('Featured picks', [entry for entry in titles if entry['featured']]),
|
||||
('New movies', [entry for entry in titles if not entry['featured'] and entry['type'] == 'movie']),
|
||||
('Fresh episodes', [entry for entry in titles if not entry['featured'] and entry['type'] == 'series'])]
|
||||
for heading, entries in sections:
|
||||
if not entries:
|
||||
continue
|
||||
body.append(f'<h2 style="font-size:20px;margin:28px 0 8px;color:#e5e1e4">{heading}</h2>')
|
||||
lines += [heading, '']
|
||||
for entry in entries:
|
||||
watch = playback_url + '/web/index.html#!/details?' + urlencode({'id': entry['id'], 'serverId': content['server_id']})
|
||||
image_data = images.get(entry['id'])
|
||||
cid = f"newsletter-{entry['id']}@magent"
|
||||
if image_data:
|
||||
source = 'data:image/jpeg;base64,' + base64.b64encode(image_data).decode() if preview else 'cid:' + cid
|
||||
poster = f'<img src="{source}" width="80" alt="{esc(entry["title"], quote=True)}" style="display:block;width:80px;height:auto;border-radius:7px;border:0">'
|
||||
if not preview:
|
||||
attachments.append({'cid': cid, 'data': image_data})
|
||||
else:
|
||||
poster = f'<div style="width:80px;height:112px;line-height:112px;background:#353039;color:#c7bdff;text-align:center;border-radius:7px;font-size:11px">{"TV" if entry["type"] == "series" else "MOVIE"}</div>'
|
||||
details = description(entry)
|
||||
overview = str(entry.get('overview') or '')[:180]
|
||||
copy = f'<p style="margin:8px 0;font-size:12px;line-height:1.6;color:#bdb6c3">{esc(overview)}</p>' if overview and entry['featured'] else ''
|
||||
body.append(f'''<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;border-bottom:1px solid #363338"><tr>
|
||||
<td width="92" valign="top" style="padding:18px 12px 18px 0">{poster}</td><td valign="top" style="padding:18px 0;overflow-wrap:anywhere">
|
||||
<h3 style="margin:0 0 8px;font-size:16px;line-height:1.4;color:#eee8f2">{esc(entry['title'])}</h3><p style="font-size:12px;line-height:1.6;color:#a69fac;margin:0 0 10px">{esc(details)}</p>{copy}
|
||||
<a href="{esc(watch, quote=True)}" style="display:inline-block;padding:8px 0;color:#c7bdff;text-decoration:none;font-size:13px;font-weight:bold">Watch on Jellyfin ↗</a></td></tr></table>''')
|
||||
lines += [entry['title'], details, watch, '']
|
||||
if not titles:
|
||||
body.append('<p style="font-size:14px;line-height:1.7;color:#bdb6c3">Your next discovery is waiting in your media library.</p>')
|
||||
period = f"{content['period_start'][:10]} to {content['period_end'][:10]} · UTC"
|
||||
footer = f'You subscribed to the Magent newsletter.<br>Arrivals recorded by Jellyfin · {esc(period)}<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from newsletters</a> · <a href="{esc(public_url + "/profile#newsletters", quote=True)}" style="color:#c7bdff">Email preferences</a>'
|
||||
subject = ('[Test] ' if test else '') + content['subject']
|
||||
return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Jellyfin: {playback_url}', '',
|
||||
f'Arrivals recorded by Jellyfin: {period}', f'Unsubscribe from newsletters: {unsubscribe_url}',
|
||||
f'Email preferences: {public_url}/profile#newsletters']),
|
||||
'body_html': document(title='What’s new in your library',
|
||||
intro=('This is your test edition. ' if test else '') + 'New stories for your watchlist. Find your next movie or catch up on fresh episodes.',
|
||||
content=''.join(body), action='Explore Jellyfin', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
|
||||
'inline_images': attachments}
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Independent newsletter consent and immutable edition snapshots using the shared email queue."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import uuid
|
||||
from contextlib import closing
|
||||
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):
|
||||
pass
|
||||
|
||||
|
||||
def init_schema(conn):
|
||||
for sql in (
|
||||
"""CREATE TABLE IF NOT EXISTS newsletter_settings (
|
||||
id INTEGER PRIMARY KEY CHECK(id=1), enabled INTEGER NOT NULL DEFAULT 0,
|
||||
weekday INTEGER NOT NULL DEFAULT 4, hour INTEGER NOT NULL DEFAULT 9, limit_titles INTEGER NOT NULL DEFAULT 12,
|
||||
public_url TEXT NOT NULL DEFAULT '', intro TEXT NOT NULL DEFAULT '', revision INTEGER NOT NULL DEFAULT 1,
|
||||
next_send_at REAL, generation_claim TEXT, generation_until REAL, generation_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '')""",
|
||||
"INSERT OR IGNORE INTO newsletter_settings (id, public_url) SELECT 1, public_url FROM email_recap_settings WHERE id=1",
|
||||
"""CREATE TABLE IF NOT EXISTS newsletter_subscriptions (
|
||||
user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
|
||||
identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
|
||||
confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
|
||||
confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
|
||||
"""CREATE TABLE IF NOT EXISTS newsletter_editions (
|
||||
id TEXT PRIMARY KEY, subject TEXT NOT NULL, intro TEXT NOT NULL, content_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1, state TEXT NOT NULL DEFAULT 'draft', origin TEXT NOT NULL DEFAULT 'manual',
|
||||
weekly_key TEXT UNIQUE, send_at REAL, created_at REAL NOT NULL, updated_at REAL NOT NULL, created_by TEXT NOT NULL)""",
|
||||
"""CREATE TABLE IF NOT EXISTS newsletter_versions (
|
||||
edition_id TEXT NOT NULL, revision INTEGER NOT NULL, content_json TEXT NOT NULL,
|
||||
PRIMARY KEY (edition_id, revision))""",
|
||||
"""CREATE TABLE IF NOT EXISTS newsletter_deliveries (
|
||||
id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
|
||||
edition_id TEXT NOT NULL, edition_revision INTEGER NOT NULL, kind TEXT NOT NULL,
|
||||
email TEXT NOT NULL, subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
|
||||
claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_newsletter_queue ON newsletter_deliveries (state, next_attempt_at)",
|
||||
"""CREATE TRIGGER IF NOT EXISTS newsletter_account_changed AFTER UPDATE OF email, is_blocked ON users
|
||||
WHEN LOWER(TRIM(COALESCE(NEW.email,''))) != LOWER(TRIM(COALESCE(OLD.email,''))) OR NEW.is_blocked=1
|
||||
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=NEW.id; END""",
|
||||
"""CREATE TRIGGER IF NOT EXISTS newsletter_account_deleted AFTER DELETE ON users
|
||||
BEGIN DELETE FROM newsletter_subscriptions WHERE user_id=OLD.id;
|
||||
UPDATE newsletter_deliveries SET state='cancelled', detail='Account removed.'
|
||||
WHERE user_id=OLD.id AND state IN ('queued','retry','preparing'); END""",
|
||||
"""CREATE TRIGGER IF NOT EXISTS newsletter_identity_changed AFTER UPDATE ON jellyfin_user_links
|
||||
WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source OR NEW.local_user_id != OLD.local_user_id
|
||||
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=OLD.local_user_id; END""",
|
||||
"""CREATE TRIGGER IF NOT EXISTS newsletter_identity_deleted AFTER DELETE ON jellyfin_user_links
|
||||
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=OLD.local_user_id; END""",
|
||||
):
|
||||
conn.execute(sql)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def public_settings() -> dict:
|
||||
return {key: value for key, value in settings().items() if key in
|
||||
{'enabled', 'weekday', 'hour', 'limit_titles', 'public_url', 'intro', 'revision', 'next_send_at', 'last_error'}}
|
||||
|
||||
|
||||
def next_due(now: datetime, weekday: int, hour: int) -> datetime:
|
||||
now = now.astimezone(timezone.utc)
|
||||
due = now.replace(hour=hour, minute=0, second=0, microsecond=0) + timedelta(days=(weekday - now.weekday()) % 7)
|
||||
return due if due > now else due + timedelta(days=7)
|
||||
|
||||
|
||||
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']:
|
||||
raise Conflict('The newsletter settings changed. Refresh before saving.')
|
||||
due = next_due(now, values['weekday'], values['hour']).timestamp() if values['enabled'] else None
|
||||
conn.execute("""UPDATE newsletter_settings SET enabled=?, weekday=?, hour=?, limit_titles=?, public_url=?, intro=?,
|
||||
revision=revision+1, next_send_at=?, generation_claim=NULL, generation_until=NULL, generation_attempts=0, last_error='' WHERE id=1""",
|
||||
(values['enabled'], values['weekday'], values['hour'], values['limit_titles'], values['public_url'], values['intro'], due))
|
||||
if not values['enabled'] or any(old[key] != values[key] for key in ('weekday', 'hour', 'public_url')):
|
||||
conn.execute("UPDATE newsletter_editions SET state='cancelled', updated_at=? WHERE origin='weekly' AND state IN ('scheduled','queued')", (now.timestamp(),))
|
||||
conn.execute("""UPDATE newsletter_deliveries SET state='cancelled', detail='Weekly schedule paused or changed.'
|
||||
WHERE state IN ('queued','retry','preparing') AND kind='edition'
|
||||
AND edition_id IN (SELECT id FROM newsletter_editions WHERE state='cancelled')""")
|
||||
return public_settings()
|
||||
|
||||
|
||||
def subscription(user_id):
|
||||
return read_one('SELECT * FROM newsletter_subscriptions WHERE user_id=?', (user_id,))
|
||||
|
||||
|
||||
def disable(user_id):
|
||||
with transaction() as conn:
|
||||
conn.execute("UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
|
||||
conn.execute("UPDATE newsletter_deliveries SET state='cancelled', detail='Newsletter subscription turned off.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (user_id,))
|
||||
|
||||
|
||||
def request_confirmation(user, source, identity, now):
|
||||
token = secrets.token_urlsafe(32)
|
||||
with transaction() as conn:
|
||||
old = conn.execute('SELECT requested_at FROM newsletter_subscriptions WHERE user_id=?', (user['id'],)).fetchone()
|
||||
if old and old[0] > now - 300:
|
||||
raise Conflict('Please wait five minutes before requesting another confirmation.')
|
||||
conn.execute("""INSERT INTO newsletter_subscriptions (user_id,state,email,identity_source,identity_id,version,
|
||||
confirmation_hash,confirmation_expires,requested_at,unsubscribe_token) VALUES (?,'pending',?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET state='pending',email=excluded.email,identity_source=excluded.identity_source,
|
||||
identity_id=excluded.identity_id,version=excluded.version,confirmation_hash=excluded.confirmation_hash,
|
||||
confirmation_expires=excluded.confirmation_expires,requested_at=excluded.requested_at,confirmed_at=NULL,
|
||||
unsubscribe_token=excluded.unsubscribe_token""",
|
||||
(user['id'], user['email'].strip(), source, identity, uuid.uuid4().hex,
|
||||
hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
|
||||
return token
|
||||
|
||||
|
||||
def token_subscription(token, action):
|
||||
if action == 'confirm':
|
||||
return read_one('SELECT * FROM newsletter_subscriptions WHERE confirmation_hash=?', (hashlib.sha256(token.encode()).hexdigest(),))
|
||||
return read_one('SELECT * FROM newsletter_subscriptions WHERE unsubscribe_token=?', (token,))
|
||||
|
||||
|
||||
def confirm(sub, now):
|
||||
with transaction() as conn:
|
||||
result = conn.execute("""UPDATE newsletter_subscriptions SET state='enabled',confirmed_at=?,confirmation_hash=NULL
|
||||
WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
|
||||
AND EXISTS (SELECT 1 FROM users u JOIN jellyfin_user_links j ON j.local_user_id=u.id
|
||||
WHERE u.id=newsletter_subscriptions.user_id AND u.is_blocked=0
|
||||
AND LOWER(TRIM(u.email))=LOWER(TRIM(newsletter_subscriptions.email))
|
||||
AND j.source=identity_source AND j.jellyfin_user_id=identity_id)""", (now, sub['user_id'], sub['version'], now))
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def unpack(row):
|
||||
if row is None:
|
||||
return None
|
||||
result = dict(row)
|
||||
result['content'] = json.loads(result.pop('content_json'))
|
||||
return result
|
||||
|
||||
|
||||
def edition(identity):
|
||||
return unpack(read_one('SELECT * FROM newsletter_editions WHERE id=?', (identity,)))
|
||||
|
||||
|
||||
def create_edition(content, subject, intro, creator, now):
|
||||
identity = uuid.uuid4().hex
|
||||
with transaction() as conn:
|
||||
conn.execute('''INSERT INTO newsletter_editions (id,subject,intro,content_json,created_at,updated_at,created_by)
|
||||
VALUES (?,?,?,?,?,?,?)''', (identity, subject, intro, json.dumps(content), now, now, creator))
|
||||
return edition(identity)
|
||||
|
||||
|
||||
def editable(conn, identity, revision):
|
||||
row = conn.execute('SELECT * FROM newsletter_editions WHERE id=?', (identity,)).fetchone()
|
||||
if not row or row['revision'] != revision:
|
||||
raise Conflict('This edition changed. Reload it before continuing.')
|
||||
if row['state'] != 'draft':
|
||||
raise Conflict('This edition is already scheduled or finished. Create a new draft to make changes.')
|
||||
return unpack(row)
|
||||
|
||||
|
||||
def update_edition(identity, revision, subject, intro, selections, now):
|
||||
with transaction() as conn:
|
||||
old = editable(conn, identity, revision)
|
||||
titles = old['content']['titles']
|
||||
selected = {entry['id']: entry for entry in selections}
|
||||
if len(selected) != len(selections) or set(selected) != {entry['id'] for entry in titles}:
|
||||
raise Conflict('The title selection does not match this draft. Reload the edition.')
|
||||
if sum(bool(entry['selected']) for entry in selections) > 24 or sum(bool(entry['featured']) for entry in selections) > 3:
|
||||
raise Conflict('Choose up to 24 titles and three featured picks.')
|
||||
if any(entry['featured'] and not entry['selected'] for entry in selections):
|
||||
raise Conflict('Featured picks must be included in the edition.')
|
||||
for entry in titles:
|
||||
entry.update(selected=selected[entry['id']]['selected'], featured=selected[entry['id']]['featured'])
|
||||
conn.execute('UPDATE newsletter_editions SET subject=?,intro=?,content_json=?,revision=revision+1,updated_at=? WHERE id=?',
|
||||
(subject, intro, json.dumps(old['content']), now, identity))
|
||||
return edition(identity)
|
||||
|
||||
|
||||
def snapshot(conn, row):
|
||||
data = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
|
||||
# Store only included titles; retries of a test retain the exact saved version.
|
||||
data['titles'] = [entry for entry in data['titles'] if entry['selected']]
|
||||
conn.execute('INSERT OR IGNORE INTO newsletter_versions (edition_id,revision,content_json) VALUES (?,?,?)',
|
||||
(row['id'], row['revision'], json.dumps(data)))
|
||||
|
||||
|
||||
def version(delivery):
|
||||
row = read_one('SELECT content_json FROM newsletter_versions WHERE edition_id=? AND revision=?', (delivery['edition_id'], delivery['edition_revision']))
|
||||
return json.loads(row['content_json']) if row else None
|
||||
|
||||
|
||||
def publish(identity, revision, send_at, now):
|
||||
with transaction() as conn:
|
||||
previous = conn.execute('SELECT revision,state FROM newsletter_editions WHERE id=?', (identity,)).fetchone()
|
||||
if previous and previous['revision'] == revision and previous['state'] in {'scheduled', 'queued', 'complete'}:
|
||||
return edition(identity)
|
||||
row = editable(conn, identity, revision)
|
||||
if not any(entry['selected'] for entry in row['content']['titles']) and not row['intro'].strip():
|
||||
raise Conflict('Add an announcement or select a title before sending.')
|
||||
snapshot(conn, row)
|
||||
conn.execute("UPDATE newsletter_editions SET state='scheduled',send_at=?,updated_at=? WHERE id=?", (send_at, now, identity))
|
||||
return edition(identity)
|
||||
|
||||
|
||||
def cancel(identity, now):
|
||||
with transaction() as conn:
|
||||
conn.execute("UPDATE newsletter_editions SET state='cancelled',updated_at=? WHERE id=? AND state IN ('draft','scheduled','queued')", (now, identity))
|
||||
conn.execute("UPDATE newsletter_deliveries SET state='cancelled',detail='Edition cancelled.',updated_at=? WHERE edition_id=? AND state IN ('queued','retry','preparing')", (now, identity))
|
||||
return edition(identity)
|
||||
|
||||
|
||||
def _enqueue(conn, sub, row, kind, key, public_url, now):
|
||||
identity = uuid.uuid4().hex
|
||||
conn.execute('''INSERT OR IGNORE INTO newsletter_deliveries (id,dedupe_key,user_id,edition_id,edition_revision,kind,email,
|
||||
subscription_version,public_url,created_at,updated_at,next_attempt_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)''',
|
||||
(identity, key, sub['user_id'], row['id'], row['revision'], kind, sub['email'], sub['version'], public_url, now, now, now))
|
||||
return conn.execute('SELECT id FROM newsletter_deliveries WHERE dedupe_key=?', (key,)).fetchone()[0]
|
||||
|
||||
|
||||
def enqueue_test(sub, identity, revision, request_id, public_url, now):
|
||||
key = f"test:{sub['user_id']}:{request_id}"
|
||||
with transaction() as conn:
|
||||
previous = conn.execute('SELECT id,edition_id,edition_revision FROM newsletter_deliveries WHERE dedupe_key=?', (key,)).fetchone()
|
||||
if previous:
|
||||
if previous['edition_id'] != identity or previous['edition_revision'] != revision:
|
||||
raise Conflict('This test request was already used for another saved version.')
|
||||
return previous['id']
|
||||
row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE id=? AND revision=?', (identity, revision)).fetchone())
|
||||
if not row or row['state'] == 'cancelled':
|
||||
raise Conflict('This edition changed or was cancelled. Reload it first.')
|
||||
if conn.execute("SELECT 1 FROM newsletter_deliveries WHERE user_id=? AND kind='test' AND created_at>?", (sub['user_id'], now-300)).fetchone():
|
||||
raise Conflict('Please wait five minutes between newsletter test emails.')
|
||||
snapshot(conn, row)
|
||||
return _enqueue(conn, sub, row, 'test', key, public_url, now)
|
||||
|
||||
|
||||
def enqueue_due(now):
|
||||
with transaction() as conn:
|
||||
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)
|
||||
subs = conn.execute("SELECT * FROM newsletter_subscriptions WHERE state='enabled' AND confirmed_at<=?", (row['send_at'],)).fetchall()
|
||||
for sub in subs:
|
||||
_enqueue(conn, sub, row, 'edition', f"edition:{row['id']}:{sub['user_id']}", config['public_url'], now)
|
||||
conn.execute("UPDATE newsletter_editions SET state=?,updated_at=? WHERE id=?", ('queued' if subs else 'complete', now, row['id']))
|
||||
|
||||
|
||||
def claim_delivery(now):
|
||||
with transaction() as conn:
|
||||
return email_queue.claim(conn, 'newsletter_deliveries', now)
|
||||
|
||||
|
||||
def begin_sending(delivery, now):
|
||||
with transaction() as conn:
|
||||
result = conn.execute("""UPDATE newsletter_deliveries SET state='sending',updated_at=?,lease_until=?
|
||||
WHERE id=? AND claim=? AND state='preparing'
|
||||
AND EXISTS (SELECT 1 FROM newsletter_subscriptions s JOIN users u ON u.id=s.user_id
|
||||
JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
|
||||
WHERE s.user_id=newsletter_deliveries.user_id AND s.state='enabled'
|
||||
AND s.version=newsletter_deliveries.subscription_version AND u.is_blocked=0
|
||||
AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
|
||||
AND EXISTS (SELECT 1 FROM newsletter_settings WHERE id=1 AND public_url=newsletter_deliveries.public_url)
|
||||
AND EXISTS (SELECT 1 FROM newsletter_editions e WHERE e.id=newsletter_deliveries.edition_id AND e.state!='cancelled')""",
|
||||
(now, now+1800, delivery['id'], delivery['claim']))
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def finish(delivery, state, detail, now, delay=0):
|
||||
with transaction() as conn:
|
||||
email_queue.finish(conn, 'newsletter_deliveries', delivery, state, detail, now, delay)
|
||||
|
||||
|
||||
def finish_editions(now):
|
||||
with transaction() as conn:
|
||||
conn.execute("""UPDATE newsletter_editions SET state='complete',updated_at=? WHERE state='queued'
|
||||
AND NOT EXISTS (SELECT 1 FROM newsletter_deliveries d WHERE d.edition_id=newsletter_editions.id
|
||||
AND d.kind='edition' AND d.state IN ('queued','preparing','sending','retry'))""", (now,))
|
||||
|
||||
|
||||
def claim_weekly(now: datetime):
|
||||
with transaction() as conn:
|
||||
config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
|
||||
stamp = now.timestamp()
|
||||
if not config['enabled'] or not config['next_send_at'] or config['next_send_at'] > stamp or (config['generation_until'] or 0) > stamp:
|
||||
return None
|
||||
claim = uuid.uuid4().hex
|
||||
conn.execute('UPDATE newsletter_settings SET generation_claim=?,generation_until=?,generation_attempts=generation_attempts+1 WHERE id=1', (claim, stamp+600))
|
||||
due = next_due(now, config['weekday'], config['hour']) - timedelta(days=7)
|
||||
return {**config, 'generation_claim': claim, 'due': due, 'generation_attempts': config['generation_attempts']+1}
|
||||
|
||||
|
||||
def complete_weekly(config, content, now: datetime, failure=''):
|
||||
with transaction() as conn:
|
||||
current = conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()
|
||||
if not current['enabled'] or current['revision'] != config['revision'] or current['generation_claim'] != config['generation_claim']:
|
||||
return
|
||||
if failure:
|
||||
retry = config['generation_attempts'] < 3
|
||||
conn.execute('''UPDATE newsletter_settings SET generation_claim=NULL,generation_until=?,last_error=?,next_send_at=?,
|
||||
generation_attempts=? WHERE id=1''', (now.timestamp()+300 if retry else None, failure,
|
||||
current['next_send_at'] if retry else next_due(now, config['weekday'], config['hour']).timestamp(),
|
||||
config['generation_attempts'] if retry else 0))
|
||||
return
|
||||
identity = uuid.uuid4().hex
|
||||
due = config['due']
|
||||
empty = not content['titles']
|
||||
conn.execute('''INSERT OR IGNORE INTO newsletter_editions
|
||||
(id,subject,intro,content_json,state,origin,weekly_key,send_at,created_at,updated_at,created_by)
|
||||
VALUES (?,?,?,?,?,'weekly',?,?,?,?,?)''',
|
||||
(identity, f"What’s new in your library · {due.strftime('%d %b %Y')}", config['intro'], json.dumps(content),
|
||||
'skipped' if empty else 'scheduled', due.isoformat(), due.timestamp(), now.timestamp(), now.timestamp(), 'Weekly schedule'))
|
||||
row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE weekly_key=?', (due.isoformat(),)).fetchone())
|
||||
if not empty:
|
||||
snapshot(conn, row)
|
||||
conn.execute('''UPDATE newsletter_settings SET next_send_at=?,generation_claim=NULL,generation_until=NULL,
|
||||
generation_attempts=0,last_error=? WHERE id=1''',
|
||||
(next_due(now, config['weekday'], config['hour']).timestamp(), 'No new arrivals for the weekly edition; no email was queued.' if empty else ''))
|
||||
|
||||
|
||||
def overview(offset=0):
|
||||
with closing(db._connect()) as conn:
|
||||
import sqlite3
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute('SELECT * FROM newsletter_editions ORDER BY created_at DESC,id LIMIT 30').fetchall()
|
||||
editions = []
|
||||
for raw in rows:
|
||||
row = unpack(raw)
|
||||
content = row.pop('content')
|
||||
row.update(period_start=content['period_start'], period_end=content['period_end'], titles=sum(entry['selected'] for entry in content['titles']))
|
||||
editions.append(row)
|
||||
deliveries = conn.execute('''SELECT d.id,d.edition_id,e.subject,d.kind,d.email,d.state,d.attempts,d.updated_at,d.next_attempt_at,
|
||||
d.detail,u.username FROM newsletter_deliveries d LEFT JOIN users u ON u.id=d.user_id
|
||||
LEFT JOIN newsletter_editions e ON e.id=d.edition_id ORDER BY d.created_at DESC,d.id LIMIT 50 OFFSET ?''', (offset,)).fetchall()
|
||||
subscribers = conn.execute("SELECT COUNT(*) FROM newsletter_subscriptions WHERE state='enabled'").fetchone()[0]
|
||||
total = conn.execute('SELECT COUNT(*) FROM newsletter_deliveries').fetchone()[0]
|
||||
return {'editions': editions, 'deliveries': [dict(row) for row in deliveries], 'subscribers': subscribers, 'total': total}
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Weekly new-arrival newsletters, manual editions and separate opt-in delivery."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from .. import db
|
||||
from ..runtime import get_runtime_settings
|
||||
from . import email_recaps, newsletter_catalog as catalog, newsletter_email as template, newsletter_store as store
|
||||
from . import recap_email as mail, recap_store
|
||||
from .invite_email import smtp_email_config_ready
|
||||
from .jellyfin_identity import linked_user_id, source_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
NewsletterError = email_recaps.RecapError
|
||||
|
||||
|
||||
def playback_url(runtime) -> str:
|
||||
value = str(runtime.jellyfin_public_url or '').strip().rstrip('/')
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme in {'https', 'http'} and parsed.hostname and not (parsed.username or parsed.password or parsed.query or parsed.fragment) and not any(c.isspace() or c in '<>"\\' for c in value):
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
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 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.'
|
||||
if not playback_url(runtime):
|
||||
return False, 'Set the public Jellyfin address in Jellyfin settings for Watch links.'
|
||||
ready, detail = smtp_email_config_ready()
|
||||
if not ready:
|
||||
return ready, detail
|
||||
if not email_recaps.worker_enabled():
|
||||
return False, 'Background automation is paused on this server.'
|
||||
return True, 'Newsletter delivery is configured.'
|
||||
|
||||
|
||||
def account_for(user):
|
||||
account = db.get_user_by_username(user.get('username', ''))
|
||||
if not account or account.get('is_blocked') or account.get('is_expired'):
|
||||
raise NewsletterError('This account cannot receive newsletters.', 403)
|
||||
return account
|
||||
|
||||
|
||||
def active_subscription(account):
|
||||
sub = store.subscription(account['id'])
|
||||
if sub and sub['state'] != 'off' and not email_recaps.binding_matches(sub, account):
|
||||
store.disable(account['id'])
|
||||
sub = store.subscription(account['id'])
|
||||
return sub
|
||||
|
||||
|
||||
def preferences(user):
|
||||
account = account_for(user)
|
||||
sub = active_subscription(account)
|
||||
runtime = get_runtime_settings()
|
||||
ready, detail = delivery_ready()
|
||||
linked = bool(linked_user_id(account['username'], runtime.jellyfin_base_url))
|
||||
email = mail.valid_email(account.get('email'))
|
||||
config = store.settings()
|
||||
state = sub['state'] if sub else 'off'
|
||||
if state == 'pending' and sub['confirmation_expires'] <= time.time():
|
||||
state = 'expired'
|
||||
return {'state': state, 'email': account.get('email'), 'can_subscribe': ready and linked and bool(email),
|
||||
'detail': detail if not ready else 'Save a valid profile email address.' if not email else
|
||||
'Link your Jellyfin account so newsletter titles match your library access.' if not linked else 'New arrivals and featured picks, in your inbox.',
|
||||
'schedule_enabled': config['enabled'], 'next_send_at': config['next_send_at'], 'weekday': config['weekday'], 'hour': config['hour'],
|
||||
'resend_after': sub['requested_at'] + 300 if sub else None}
|
||||
|
||||
|
||||
async def subscribe(user):
|
||||
account = account_for(user)
|
||||
preference = preferences(user)
|
||||
if preference['state'] == 'enabled':
|
||||
return preference
|
||||
if not preference['can_subscribe']:
|
||||
raise NewsletterError(preference['detail'])
|
||||
runtime = get_runtime_settings()
|
||||
try:
|
||||
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
|
||||
linked_user_id(account['username'], runtime.jellyfin_base_url), time.time())
|
||||
except store.Conflict as exc:
|
||||
raise NewsletterError(str(exc), 429) from exc
|
||||
# The click supplies separate newsletter consent. Reuse a still-valid confirmed address if available.
|
||||
recap = recap_store.subscription(account['id'])
|
||||
if recap and recap['state'] == 'enabled' and email_recaps.binding_matches(recap, account):
|
||||
if store.confirm(store.subscription(account['id']), time.time()):
|
||||
return {**preferences(user), 'message': 'Newsletter subscription is on, using your confirmed profile email.'}
|
||||
config = store.settings()
|
||||
url = config['public_url'] + '/newsletter-subscription#' + urlencode({'action': 'confirm', 'token': token})
|
||||
try:
|
||||
await asyncio.to_thread(mail.send_email, account['email'].strip(), template.render_confirmation(account['username'], url),
|
||||
mail.message_id(uuid.uuid4().hex, config['public_url']))
|
||||
except mail.DeliveryError as exc:
|
||||
raise NewsletterError('Could not confirm delivery of the verification email. Check your inbox; another can be requested in five minutes.', 502) from exc
|
||||
return {**preferences(user), 'message': 'Check your inbox and confirm within 24 hours to turn on newsletters.'}
|
||||
|
||||
|
||||
def token_action(token, action, apply=False):
|
||||
sub = store.token_subscription(token, action)
|
||||
if not sub:
|
||||
raise NewsletterError('This newsletter link is invalid or has already been used. Open Profile to manage your subscription.', 410)
|
||||
if action == 'unsubscribe':
|
||||
if apply:
|
||||
store.disable(sub['user_id'])
|
||||
return {'action': action, 'state': 'off' if apply or sub['state'] == 'off' else 'ready'}
|
||||
account = db.get_user_by_id(sub['user_id'])
|
||||
if sub['state'] != 'pending' or sub['confirmation_expires'] <= time.time() or not email_recaps.binding_matches(sub, account):
|
||||
raise NewsletterError('This confirmation expired or your account changed. Request a new newsletter link in Profile.', 410)
|
||||
if apply and not store.confirm(sub, time.time()):
|
||||
raise NewsletterError('This confirmation is no longer available. Request a new newsletter link in Profile.', 410)
|
||||
return {'action': action, 'state': 'enabled' if apply else 'ready'}
|
||||
|
||||
|
||||
async def collect(start, end, limit):
|
||||
runtime = get_runtime_settings()
|
||||
result = await asyncio.wait_for(catalog.collect(runtime, start, end, limit), timeout=180)
|
||||
return {**result, 'playback_url': playback_url(runtime)}
|
||||
|
||||
|
||||
async def create_draft(user, days):
|
||||
end = datetime.now(timezone.utc)
|
||||
config = store.settings()
|
||||
content = await collect(end - timedelta(days=days), end, config['limit_titles'])
|
||||
return store.create_edition(content, f"What’s new in your library · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
|
||||
|
||||
|
||||
def require_edition(identity, revision=None):
|
||||
row = store.edition(identity)
|
||||
if not row:
|
||||
raise NewsletterError('Newsletter edition not found.', 404)
|
||||
if revision is not None and row['revision'] != revision:
|
||||
raise NewsletterError('This edition changed. Reload it before continuing.')
|
||||
return row
|
||||
|
||||
|
||||
async def preview(identity, revision):
|
||||
row = require_edition(identity, revision)
|
||||
runtime = get_runtime_settings()
|
||||
config = store.settings()
|
||||
if not config['public_url'] or not playback_url(runtime):
|
||||
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']}
|
||||
images = await asyncio.wait_for(catalog.posters(runtime, content), timeout=90)
|
||||
rendered = template.render(content, images, config['public_url'], content['playback_url'], config['public_url'] + '/profile#newsletters', preview=True)
|
||||
rendered.pop('inline_images')
|
||||
return {'id': row['id'], 'revision': row['revision'], **rendered}
|
||||
|
||||
|
||||
def queue_test(user, identity, revision, request_id):
|
||||
ready, detail = delivery_ready()
|
||||
if not ready:
|
||||
raise NewsletterError(detail)
|
||||
account = account_for(user)
|
||||
sub = active_subscription(account)
|
||||
if not sub or sub['state'] != 'enabled':
|
||||
raise NewsletterError('Subscribe to newsletters and confirm your email in Profile before sending yourself a test.')
|
||||
delivery_id = store.enqueue_test(sub, identity, revision, request_id, store.settings()['public_url'], time.time())
|
||||
return {'id': delivery_id, 'message': 'Test queued for your confirmed newsletter email. Delivery history will show the result.'}
|
||||
|
||||
|
||||
def publish(identity, revision, send_at):
|
||||
ready, detail = delivery_ready()
|
||||
if not ready:
|
||||
raise NewsletterError(detail)
|
||||
row = require_edition(identity, revision)
|
||||
runtime = get_runtime_settings()
|
||||
if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
|
||||
raise NewsletterError('The Jellyfin connection changed. Create a fresh draft before sending.')
|
||||
now = datetime.now(timezone.utc)
|
||||
when = now if send_at is None else send_at
|
||||
if when.tzinfo is None:
|
||||
raise NewsletterError('Choose a send time with an explicit timezone.', 422)
|
||||
when = when.astimezone(timezone.utc)
|
||||
if send_at is not None and not now + timedelta(seconds=30) <= when <= now + timedelta(days=90):
|
||||
raise NewsletterError('Schedule the edition at least 30 seconds ahead and within the next 90 days.', 422)
|
||||
return store.publish(identity, revision, when.timestamp(), now.timestamp())
|
||||
|
||||
|
||||
def eligible(delivery):
|
||||
account = db.get_user_by_id(delivery['user_id'])
|
||||
sub = active_subscription(account) if account else None
|
||||
ready, _ = delivery_ready()
|
||||
if not ready or not sub or sub['state'] != 'enabled' or sub['version'] != delivery['subscription_version'] or sub['email'] != delivery['email'] or not email_recaps.binding_matches(sub, account) or store.settings()['public_url'] != delivery['public_url']:
|
||||
raise mail.DeliveryCancelled()
|
||||
row = store.edition(delivery['edition_id'])
|
||||
if not row or row['state'] == 'cancelled':
|
||||
raise mail.DeliveryCancelled()
|
||||
return account, sub
|
||||
|
||||
|
||||
async def process_delivery(delivery):
|
||||
state, detail, delay = 'failed', 'Could not prepare this newsletter.', 0
|
||||
try:
|
||||
_, sub = eligible(delivery)
|
||||
content = store.version(delivery)
|
||||
runtime = get_runtime_settings()
|
||||
if not content or content['playback_url'] != playback_url(runtime) or content['source'] != source_key(runtime.jellyfin_base_url):
|
||||
raise mail.DeliveryCancelled()
|
||||
content = await asyncio.wait_for(catalog.for_recipient(runtime, content, sub['identity_id']), timeout=120)
|
||||
if content.get('recipient_disabled') or (not content['titles'] and not content['intro'].strip()):
|
||||
state, detail = 'skipped', 'No selected titles are available to this account.'
|
||||
else:
|
||||
images = await asyncio.wait_for(catalog.posters(runtime, content), timeout=90)
|
||||
unsubscribe = delivery['public_url'] + '/newsletter-subscription#' + urlencode({'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
|
||||
rendered = template.render(content, images, delivery['public_url'], content['playback_url'], unsubscribe, test=delivery['kind'] == 'test')
|
||||
|
||||
def before_data():
|
||||
eligible(delivery)
|
||||
if not store.begin_sending(delivery, time.time()):
|
||||
raise mail.DeliveryCancelled()
|
||||
|
||||
await asyncio.to_thread(mail.send_email, delivery['email'], rendered, mail.message_id(delivery['id'], delivery['public_url']), before_data)
|
||||
state, detail = 'sent', 'Accepted by the mail server.'
|
||||
except mail.DeliveryCancelled:
|
||||
state, detail = 'cancelled', 'Subscription, account, edition or email settings changed.'
|
||||
except (catalog.CatalogError, TimeoutError):
|
||||
state, detail = 'retry', 'Jellyfin content or library access could not be checked.'
|
||||
except mail.DeliveryError as exc:
|
||||
state, detail = exc.state, exc.detail
|
||||
except Exception as exc:
|
||||
logger.error('newsletter delivery error id=%s type=%s', delivery['id'], type(exc).__name__)
|
||||
current = store.read_one('SELECT state FROM newsletter_deliveries WHERE id=?', (delivery['id'],))
|
||||
if current and current['state'] == 'sending':
|
||||
state, detail = 'unknown', 'Delivery outcome is unknown; check the mail server.'
|
||||
if state == 'retry':
|
||||
if delivery['attempts'] >= 3:
|
||||
state, detail = 'failed', detail + ' Stopped after three attempts.'
|
||||
else:
|
||||
delay = 300 if delivery['attempts'] == 1 else 1800
|
||||
store.finish(delivery, state, detail, time.time(), delay)
|
||||
|
||||
|
||||
async def run_once():
|
||||
if delivery_ready()[0]:
|
||||
config = store.claim_weekly(datetime.now(timezone.utc))
|
||||
if config:
|
||||
try:
|
||||
content = await collect(config['due'] - timedelta(days=7), config['due'], config['limit_titles'])
|
||||
store.complete_weekly(config, content, datetime.now(timezone.utc))
|
||||
except (catalog.CatalogError, TimeoutError):
|
||||
store.complete_weekly(config, None, datetime.now(timezone.utc), 'Could not collect a complete weekly edition from Jellyfin. No newsletter was queued.')
|
||||
store.enqueue_due(time.time())
|
||||
for _ in range(10):
|
||||
delivery = store.claim_delivery(time.time())
|
||||
if not delivery:
|
||||
break
|
||||
await process_delivery(delivery)
|
||||
store.finish_editions(time.time())
|
||||
|
||||
|
||||
async def run_newsletter_loop():
|
||||
while True:
|
||||
try:
|
||||
await run_once()
|
||||
except Exception as exc:
|
||||
logger.error('newsletter worker failed type=%s', type(exc).__name__)
|
||||
await asyncio.sleep(30)
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import settings as env_settings
|
||||
from ..db import get_setting
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..runtime import get_runtime_settings
|
||||
from .invite_email import send_generic_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _clean_text(value: Any, fallback: str = "") -> str:
|
||||
if value is None:
|
||||
return fallback
|
||||
if isinstance(value, str):
|
||||
trimmed = value.strip()
|
||||
return trimmed if trimmed else fallback
|
||||
return str(value)
|
||||
|
||||
|
||||
def _split_emails(value: str) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
|
||||
return [entry for entry in parts if entry and "@" in entry]
|
||||
|
||||
|
||||
def _resolve_app_url() -> str:
|
||||
runtime = get_runtime_settings()
|
||||
for candidate in (
|
||||
runtime.magent_application_url,
|
||||
runtime.magent_proxy_base_url,
|
||||
env_settings.cors_allow_origin,
|
||||
):
|
||||
normalized = _clean_text(candidate)
|
||||
if normalized:
|
||||
return normalized.rstrip("/")
|
||||
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
|
||||
return f"http://localhost:{port}"
|
||||
|
||||
|
||||
def _portal_item_url(item_id: int) -> str:
|
||||
return f"{_resolve_app_url()}/portal?item={item_id}"
|
||||
|
||||
|
||||
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
validate_notification_target_url(url)
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = response.text
|
||||
return {"status_code": response.status_code, "body": body}
|
||||
|
||||
|
||||
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
|
||||
runtime.discord_webhook_url
|
||||
)
|
||||
if not webhook:
|
||||
return {"status": "skipped", "detail": "Discord webhook not configured."}
|
||||
data = {
|
||||
"content": f"**{title}**\n{message}",
|
||||
"embeds": [
|
||||
{
|
||||
"title": title,
|
||||
"description": message,
|
||||
"fields": [
|
||||
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
|
||||
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
|
||||
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
|
||||
],
|
||||
"url": _clean_text(payload.get("item_url")),
|
||||
}
|
||||
],
|
||||
}
|
||||
result = await _http_post_json(webhook, data)
|
||||
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
|
||||
|
||||
|
||||
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
||||
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
||||
if not bot_token or not chat_id:
|
||||
return {"status": "skipped", "detail": "Telegram is not configured."}
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
|
||||
result = await _http_post_json(url, payload)
|
||||
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
|
||||
|
||||
|
||||
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
webhook = _clean_text(runtime.magent_notify_webhook_url)
|
||||
if not webhook:
|
||||
return {"status": "skipped", "detail": "Generic webhook is not configured."}
|
||||
result = await _http_post_json(webhook, payload)
|
||||
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
|
||||
|
||||
|
||||
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
token = _clean_text(runtime.magent_notify_push_token)
|
||||
topic = _clean_text(runtime.magent_notify_push_topic)
|
||||
if provider == "ntfy":
|
||||
if not base_url or not topic:
|
||||
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
|
||||
validate_notification_target_url(base_url)
|
||||
url = f"{base_url.rstrip('/')}/{quote(topic)}"
|
||||
headers = {"Title": title, "Tags": "magent,portal"}
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
|
||||
response.raise_for_status()
|
||||
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
|
||||
if provider == "gotify":
|
||||
if not base_url or not token:
|
||||
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
|
||||
validate_notification_target_url(base_url)
|
||||
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
|
||||
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
|
||||
result = await _http_post_json(url, body)
|
||||
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
|
||||
if provider == "pushover":
|
||||
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
||||
if not token or not user_key:
|
||||
return {"status": "skipped", "detail": "Pushover needs token and user key."}
|
||||
form = {"token": token, "user": user_key, "title": title, "message": message}
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
|
||||
response.raise_for_status()
|
||||
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
|
||||
if provider == "discord":
|
||||
return await _send_discord(title, message, payload)
|
||||
if provider == "telegram":
|
||||
return await _send_telegram(title, message)
|
||||
if provider == "webhook":
|
||||
return await _send_webhook(payload)
|
||||
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
|
||||
|
||||
|
||||
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
|
||||
fallback = _clean_text(runtime.magent_notify_email_from_address)
|
||||
if fallback and fallback not in recipients:
|
||||
recipients.append(fallback)
|
||||
if not recipients:
|
||||
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
|
||||
|
||||
body_text = (
|
||||
f"{title}\n\n"
|
||||
f"{message}\n\n"
|
||||
f"Kind: {_clean_text(payload.get('kind'))}\n"
|
||||
f"Status: {_clean_text(payload.get('status'))}\n"
|
||||
f"Priority: {_clean_text(payload.get('priority'))}\n"
|
||||
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
|
||||
f"Open: {_clean_text(payload.get('item_url'))}\n"
|
||||
)
|
||||
body_html = (
|
||||
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
|
||||
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
|
||||
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
|
||||
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
|
||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
|
||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
|
||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
|
||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
|
||||
"</table>"
|
||||
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
|
||||
"</div>"
|
||||
)
|
||||
deliveries: list[Dict[str, Any]] = []
|
||||
for recipient in recipients:
|
||||
try:
|
||||
result = await send_generic_email(
|
||||
recipient_email=recipient,
|
||||
subject=title,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
)
|
||||
deliveries.append({"recipient": recipient, "status": "ok", **result})
|
||||
except Exception as exc:
|
||||
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
|
||||
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
|
||||
if successful:
|
||||
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
|
||||
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
|
||||
|
||||
|
||||
async def send_portal_notification(
|
||||
*,
|
||||
event_type: str,
|
||||
item: Dict[str, Any],
|
||||
actor_username: str,
|
||||
actor_role: str,
|
||||
note: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.magent_notify_enabled:
|
||||
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
|
||||
|
||||
item_id = int(item.get("id") or 0)
|
||||
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
|
||||
message_lines = [
|
||||
f"Event: {event_type}",
|
||||
f"Actor: {actor_username} ({actor_role})",
|
||||
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
|
||||
]
|
||||
if note:
|
||||
message_lines.append(f"Note: {note}")
|
||||
message_lines.append(f"Open: {_portal_item_url(item_id)}")
|
||||
message = "\n".join(message_lines)
|
||||
payload = {
|
||||
"type": "portal.notification",
|
||||
"event": event_type,
|
||||
"item_id": item_id,
|
||||
"item_url": _portal_item_url(item_id),
|
||||
"kind": _clean_text(item.get("kind")),
|
||||
"status": _clean_text(item.get("status")),
|
||||
"priority": _clean_text(item.get("priority")),
|
||||
"requested_by": _clean_text(item.get("created_by_username")),
|
||||
"actor_username": actor_username,
|
||||
"actor_role": actor_role,
|
||||
"note": note or "",
|
||||
}
|
||||
|
||||
channels: Dict[str, Dict[str, Any]] = {}
|
||||
if runtime.magent_notify_discord_enabled:
|
||||
try:
|
||||
channels["discord"] = await _send_discord(title, message, payload)
|
||||
except Exception as exc:
|
||||
channels["discord"] = {"status": "error", "detail": str(exc)}
|
||||
if runtime.magent_notify_telegram_enabled:
|
||||
try:
|
||||
channels["telegram"] = await _send_telegram(title, message)
|
||||
except Exception as exc:
|
||||
channels["telegram"] = {"status": "error", "detail": str(exc)}
|
||||
if runtime.magent_notify_webhook_enabled:
|
||||
try:
|
||||
channels["webhook"] = await _send_webhook(payload)
|
||||
except Exception as exc:
|
||||
channels["webhook"] = {"status": "error", "detail": str(exc)}
|
||||
if runtime.magent_notify_push_enabled:
|
||||
try:
|
||||
channels["push"] = await _send_push(title, message, payload)
|
||||
except Exception as exc:
|
||||
channels["push"] = {"status": "error", "detail": str(exc)}
|
||||
if runtime.magent_notify_email_enabled:
|
||||
try:
|
||||
channels["email"] = await _send_email(title, message, payload)
|
||||
except Exception as exc:
|
||||
channels["email"] = {"status": "error", "detail": str(exc)}
|
||||
|
||||
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
|
||||
failed = [name for name, value in channels.items() if value.get("status") == "error"]
|
||||
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
|
||||
logger.info(
|
||||
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
|
||||
event_type,
|
||||
item_id,
|
||||
successful,
|
||||
failed,
|
||||
skipped,
|
||||
)
|
||||
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
|
||||
if not channels:
|
||||
overall = "skipped"
|
||||
return {"status": overall, "channels": channels}
|
||||
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
|
||||
_OPERATION_TTL_SECONDS = 15 * 60
|
||||
_MAX_OPERATIONS = 500
|
||||
_MAX_EVENTS = 60
|
||||
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
|
||||
"magent_operation_id", default=None
|
||||
)
|
||||
_operations: Dict[str, Dict[str, Any]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
|
||||
|
||||
|
||||
def _prune_locked(now_monotonic: float) -> None:
|
||||
expired = [
|
||||
operation_id
|
||||
for operation_id, operation in _operations.items()
|
||||
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
|
||||
]
|
||||
for operation_id in expired:
|
||||
_operations.pop(operation_id, None)
|
||||
if len(_operations) <= _MAX_OPERATIONS:
|
||||
return
|
||||
oldest = sorted(
|
||||
_operations,
|
||||
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
|
||||
)
|
||||
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
|
||||
_operations.pop(operation_id, None)
|
||||
|
||||
|
||||
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
|
||||
now_monotonic = time.monotonic()
|
||||
now_iso = _now_iso()
|
||||
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
|
||||
with _lock:
|
||||
_prune_locked(now_monotonic)
|
||||
_operations[operation_id] = {
|
||||
"id": operation_id,
|
||||
"label": normalized_label,
|
||||
"path": path,
|
||||
"status": "running",
|
||||
"started_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
"updated_monotonic": now_monotonic,
|
||||
"duration_ms": None,
|
||||
"events": [
|
||||
{
|
||||
"id": uuid.uuid4().hex,
|
||||
"service": "Magent",
|
||||
"state": "complete",
|
||||
"message": "Your action has been received. Magent is starting the checks.",
|
||||
"started_at": now_iso,
|
||||
"finished_at": now_iso,
|
||||
"duration_ms": 0,
|
||||
"status_code": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
return _current_operation_id.set(operation_id)
|
||||
|
||||
|
||||
def reset_operation(token: Token) -> None:
|
||||
_current_operation_id.reset(token)
|
||||
|
||||
|
||||
def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
|
||||
operation_id = _current_operation_id.get()
|
||||
if not operation_id:
|
||||
return None
|
||||
event_id = uuid.uuid4().hex
|
||||
now_iso = _now_iso()
|
||||
now_monotonic = time.monotonic()
|
||||
with _lock:
|
||||
operation = _operations.get(operation_id)
|
||||
if not operation:
|
||||
return None
|
||||
operation["events"].append(
|
||||
{
|
||||
"id": event_id,
|
||||
"service": service,
|
||||
"state": "active",
|
||||
"message": message or f"Contacting {service}…",
|
||||
"started_at": now_iso,
|
||||
"finished_at": None,
|
||||
"duration_ms": None,
|
||||
"status_code": None,
|
||||
"started_monotonic": now_monotonic,
|
||||
}
|
||||
)
|
||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
||||
operation["updated_at"] = now_iso
|
||||
operation["updated_monotonic"] = now_monotonic
|
||||
return event_id
|
||||
|
||||
|
||||
def finish_remote_call(
|
||||
event_id: Optional[str],
|
||||
*,
|
||||
success: bool,
|
||||
status_code: Optional[int] = None,
|
||||
message: Optional[str] = None,
|
||||
) -> None:
|
||||
operation_id = _current_operation_id.get()
|
||||
if not operation_id or not event_id:
|
||||
return
|
||||
now_iso = _now_iso()
|
||||
now_monotonic = time.monotonic()
|
||||
with _lock:
|
||||
operation = _operations.get(operation_id)
|
||||
if not operation:
|
||||
return
|
||||
event = next(
|
||||
(candidate for candidate in operation["events"] if candidate.get("id") == event_id),
|
||||
None,
|
||||
)
|
||||
if not event:
|
||||
return
|
||||
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
||||
event["state"] = "complete" if success else "error"
|
||||
event["finished_at"] = now_iso
|
||||
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
||||
event["status_code"] = status_code
|
||||
event["message"] = message or (
|
||||
f"{event['service']} responded successfully."
|
||||
if success
|
||||
else f"{event['service']} returned an error."
|
||||
)
|
||||
operation["updated_at"] = now_iso
|
||||
operation["updated_monotonic"] = now_monotonic
|
||||
|
||||
|
||||
def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
|
||||
now_iso = _now_iso()
|
||||
now_monotonic = time.monotonic()
|
||||
with _lock:
|
||||
operation = _operations.get(operation_id)
|
||||
if not operation:
|
||||
return
|
||||
for event in operation["events"]:
|
||||
if event.get("state") == "active":
|
||||
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
||||
event["state"] = "error"
|
||||
event["finished_at"] = now_iso
|
||||
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
||||
event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
|
||||
started = datetime.fromisoformat(str(operation["started_at"]))
|
||||
duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
|
||||
operation["status"] = "complete" if success else "error"
|
||||
operation["status_code"] = status_code
|
||||
operation["duration_ms"] = round(duration_ms, 1)
|
||||
operation["updated_at"] = now_iso
|
||||
operation["updated_monotonic"] = now_monotonic
|
||||
operation["events"].append(
|
||||
{
|
||||
"id": uuid.uuid4().hex,
|
||||
"service": "Magent",
|
||||
"state": "complete" if success else "error",
|
||||
"message": (
|
||||
"This action has finished. Check the request status for what happens next."
|
||||
if success
|
||||
else "This action could not be completed. Open the activity details to see which step needs attention."
|
||||
),
|
||||
"started_at": now_iso,
|
||||
"finished_at": now_iso,
|
||||
"duration_ms": 0,
|
||||
"status_code": status_code,
|
||||
}
|
||||
)
|
||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
||||
|
||||
|
||||
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
|
||||
normalized = normalize_operation_id(operation_id)
|
||||
if not normalized:
|
||||
return None
|
||||
with _lock:
|
||||
operation = _operations.get(normalized)
|
||||
if not operation:
|
||||
return None
|
||||
result = deepcopy(operation)
|
||||
result.pop("updated_monotonic", None)
|
||||
for event in result.get("events", []):
|
||||
event.pop("started_monotonic", None)
|
||||
return result
|
||||
@@ -0,0 +1,335 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..db import (
|
||||
create_password_reset_token,
|
||||
delete_expired_password_reset_tokens,
|
||||
get_password_reset_token,
|
||||
get_user_by_jellyseerr_id,
|
||||
get_user_by_username,
|
||||
get_users_by_username_ci,
|
||||
mark_password_reset_token_used,
|
||||
set_user_auth_provider,
|
||||
set_user_password,
|
||||
increment_user_auth_version,
|
||||
sync_jellyfin_password_state,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from .invite_email import send_password_reset_email
|
||||
from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
|
||||
|
||||
|
||||
class PasswordResetUnavailableError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_handles(value: object) -> list[str]:
|
||||
if not isinstance(value, str):
|
||||
return []
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
return []
|
||||
handles = [normalized]
|
||||
if "@" in normalized:
|
||||
handles.append(normalized.split("@", 1)[0])
|
||||
return list(dict.fromkeys(handles))
|
||||
|
||||
|
||||
def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
|
||||
if not users:
|
||||
return None
|
||||
requested = str(requested_identifier or "").strip().lower()
|
||||
|
||||
def _rank(user: dict) -> tuple[int, int, int, int]:
|
||||
provider = str(user.get("auth_provider") or "local").strip().lower()
|
||||
role = str(user.get("role") or "user").strip().lower()
|
||||
username = str(user.get("username") or "").strip().lower()
|
||||
return (
|
||||
0 if role == "admin" else 1,
|
||||
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
|
||||
0 if provider == "jellyfin" else (1 if provider == "local" else 2),
|
||||
0 if username == requested else 1,
|
||||
)
|
||||
|
||||
return sorted(users, key=_rank)[0]
|
||||
|
||||
|
||||
def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
|
||||
target_handles = set(_normalize_handles(identifier))
|
||||
if not target_handles:
|
||||
return None
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
for key in ("username", "email"):
|
||||
value = user.get(key)
|
||||
if target_handles.intersection(_normalize_handles(value)):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_all_seerr_users() -> list[dict]:
|
||||
cached = get_cached_jellyseerr_users()
|
||||
if cached is not None:
|
||||
return cached
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.configured():
|
||||
return []
|
||||
users: list[dict] = []
|
||||
take = 100
|
||||
skip = 0
|
||||
while True:
|
||||
payload = await client.get_users(take=take, skip=skip)
|
||||
if not payload:
|
||||
break
|
||||
if isinstance(payload, list):
|
||||
batch = payload
|
||||
elif isinstance(payload, dict):
|
||||
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
|
||||
else:
|
||||
batch = None
|
||||
if not isinstance(batch, list) or not batch:
|
||||
break
|
||||
users.extend([user for user in batch if isinstance(user, dict)])
|
||||
if len(batch) < take:
|
||||
break
|
||||
skip += take
|
||||
if users:
|
||||
return save_jellyseerr_users_cache(users)
|
||||
return users
|
||||
|
||||
|
||||
def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
|
||||
if isinstance(local_user, dict):
|
||||
stored_email = str(local_user.get("email") or "").strip()
|
||||
if "@" in stored_email:
|
||||
return stored_email
|
||||
username = str(local_user.get("username") or "").strip()
|
||||
if "@" in username:
|
||||
return username
|
||||
if isinstance(seerr_user, dict):
|
||||
email = str(seerr_user.get("email") or "").strip()
|
||||
if "@" in email:
|
||||
return email
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
|
||||
normalized_identifier = str(identifier or "").strip()
|
||||
if not normalized_identifier:
|
||||
return None
|
||||
|
||||
local_user = normalize_user_auth_provider(
|
||||
_pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
|
||||
)
|
||||
seerr_users: list[dict] | None = None
|
||||
seerr_user: dict | None = None
|
||||
|
||||
if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
|
||||
seerr_users = await _fetch_all_seerr_users()
|
||||
seerr_user = next(
|
||||
(
|
||||
user
|
||||
for user in seerr_users
|
||||
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not local_user:
|
||||
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
|
||||
seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
|
||||
if seerr_user:
|
||||
seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
|
||||
try:
|
||||
seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
seerr_user_id = None
|
||||
if seerr_user_id is not None:
|
||||
local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
|
||||
if not local_user:
|
||||
for candidate in (seerr_user.get("email"), seerr_user.get("username")):
|
||||
if not isinstance(candidate, str) or not candidate.strip():
|
||||
continue
|
||||
local_user = normalize_user_auth_provider(
|
||||
_pick_preferred_user(get_users_by_username_ci(candidate), candidate)
|
||||
)
|
||||
if local_user:
|
||||
break
|
||||
|
||||
if not local_user:
|
||||
return None
|
||||
|
||||
auth_provider = resolve_user_auth_provider(local_user)
|
||||
username = str(local_user.get("username") or "").strip()
|
||||
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
|
||||
if not recipient_email:
|
||||
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
|
||||
if isinstance(local_user.get("jellyseerr_user_id"), int):
|
||||
seerr_user = next(
|
||||
(
|
||||
user
|
||||
for user in seerr_users
|
||||
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not seerr_user:
|
||||
seerr_user = _find_matching_seerr_user(username, seerr_users)
|
||||
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
|
||||
if not recipient_email:
|
||||
return None
|
||||
|
||||
if auth_provider == "jellyseerr":
|
||||
runtime = get_runtime_settings()
|
||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if jellyfin_client.configured():
|
||||
try:
|
||||
jellyfin_user = await jellyfin_client.find_user_by_name(username)
|
||||
except Exception:
|
||||
jellyfin_user = None
|
||||
if isinstance(jellyfin_user, dict):
|
||||
auth_provider = "jellyfin"
|
||||
|
||||
if auth_provider not in {"local", "jellyfin"}:
|
||||
return None
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"recipient_email": recipient_email,
|
||||
"auth_provider": auth_provider,
|
||||
}
|
||||
|
||||
|
||||
def _token_record_is_usable(record: Optional[dict]) -> bool:
|
||||
if not isinstance(record, dict):
|
||||
return False
|
||||
if record.get("is_used"):
|
||||
return False
|
||||
if record.get("is_expired"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
candidate = str(email or "").strip()
|
||||
if "@" not in candidate:
|
||||
return "valid reset link"
|
||||
local_part, domain = candidate.split("@", 1)
|
||||
if not local_part:
|
||||
return f"***@{domain}"
|
||||
if len(local_part) == 1:
|
||||
return f"{local_part}***@{domain}"
|
||||
return f"{local_part[0]}***{local_part[-1]}@{domain}"
|
||||
|
||||
|
||||
async def request_password_reset(
|
||||
identifier: str,
|
||||
*,
|
||||
requested_by_ip: Optional[str] = None,
|
||||
requested_user_agent: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
delete_expired_password_reset_tokens()
|
||||
target = await _resolve_reset_target(identifier)
|
||||
if not target:
|
||||
logger.info("password reset requested with no eligible match")
|
||||
return {"status": "ok", "issued": False}
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
|
||||
create_password_reset_token(
|
||||
token,
|
||||
target["username"],
|
||||
target["recipient_email"],
|
||||
target["auth_provider"],
|
||||
expires_at,
|
||||
requested_by_ip=requested_by_ip,
|
||||
requested_user_agent=requested_user_agent,
|
||||
)
|
||||
await send_password_reset_email(
|
||||
recipient_email=target["recipient_email"],
|
||||
username=target["username"],
|
||||
token=token,
|
||||
expires_at=expires_at,
|
||||
auth_provider=target["auth_provider"],
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"issued": True,
|
||||
"username": target["username"],
|
||||
"recipient_email": target["recipient_email"],
|
||||
"auth_provider": target["auth_provider"],
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
|
||||
def verify_password_reset_token(token: str) -> Dict[str, Any]:
|
||||
delete_expired_password_reset_tokens()
|
||||
record = get_password_reset_token(token)
|
||||
if not _token_record_is_usable(record):
|
||||
raise ValueError("Password reset link is invalid or has expired.")
|
||||
return {
|
||||
"status": "ok",
|
||||
"recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
|
||||
"auth_provider": record.get("auth_provider"),
|
||||
"expires_at": record.get("expires_at"),
|
||||
}
|
||||
|
||||
|
||||
async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
|
||||
delete_expired_password_reset_tokens()
|
||||
record = get_password_reset_token(token)
|
||||
if not _token_record_is_usable(record):
|
||||
raise ValueError("Password reset link is invalid or has expired.")
|
||||
|
||||
username = str(record.get("username") or "").strip()
|
||||
if not username:
|
||||
raise ValueError("Password reset link is invalid or has expired.")
|
||||
|
||||
stored_user = normalize_user_auth_provider(get_user_by_username(username))
|
||||
if not stored_user:
|
||||
raise ValueError("Password reset link is invalid or has expired.")
|
||||
|
||||
auth_provider = resolve_user_auth_provider(stored_user)
|
||||
if auth_provider == "jellyseerr":
|
||||
auth_provider = "jellyfin"
|
||||
|
||||
if auth_provider == "local":
|
||||
set_user_password(username, new_password)
|
||||
if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
|
||||
set_user_auth_provider(username, "local")
|
||||
mark_password_reset_token_used(token)
|
||||
logger.info("password reset applied username=%s provider=local", username)
|
||||
return {"status": "ok", "provider": "local", "username": username}
|
||||
|
||||
if auth_provider == "jellyfin":
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
|
||||
jellyfin_user = await client.find_user_by_name(username)
|
||||
user_id = client._extract_user_id(jellyfin_user)
|
||||
if not user_id:
|
||||
raise ValueError("Password reset link is invalid or has expired.")
|
||||
await client.set_user_password(user_id, new_password)
|
||||
sync_jellyfin_password_state(username, new_password)
|
||||
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)
|
||||
logger.info("password reset applied username=%s provider=jellyfin", username)
|
||||
return {"status": "ok", "provider": "jellyfin", "username": username}
|
||||
|
||||
raise ValueError("Password reset is not available for this sign-in provider.")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Configured public email links, independent of request Host/forwarded headers."""
|
||||
from urllib.parse import urlsplit
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..installation_origin import managed_runtime
|
||||
|
||||
|
||||
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 managed_runtime():
|
||||
return valid_public_url(application)
|
||||
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)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Personal recap email rendering and SMTP delivery with explicit acceptance tracking."""
|
||||
|
||||
import html
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP as SMTP_POLICY
|
||||
from email.utils import formataddr, formatdate
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
|
||||
class DeliveryError(Exception):
|
||||
def __init__(self, state: str, detail: str):
|
||||
self.state, self.detail = state, detail
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
class DeliveryCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def valid_email(value: str | None) -> str | None:
|
||||
value = str(value or "").strip()
|
||||
if (len(value) <= 254 and re.fullmatch(r"[^@\s<>;,\"\\]+@[^@\s<>;,\"\\]+\.[^@\s<>;,\"\\]+", value)
|
||||
and all(32 < ord(char) < 127 for char in value)):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def month_label(value: str) -> str:
|
||||
return datetime.strptime(value, "%Y-%m").strftime("%B %Y")
|
||||
|
||||
|
||||
def number(value: float) -> str:
|
||||
return f"{value:,.0f}"
|
||||
|
||||
|
||||
def document(*, title: str, intro: str, content: str, action: str, url: str, footer: str, kicker: str = 'YOUR MONTH IN VIEWING') -> str:
|
||||
esc = html.escape
|
||||
return f'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="dark"><title>{esc(title)}</title><style>@media(max-width:280px){{.email-metrics td{{display:block!important;width:auto!important;padding:16px 0!important}}.email-metrics tr{{display:block!important}}}}</style></head>
|
||||
<body style="margin:0;padding:0;background:#131315;color:#e5e1e4;font-family:Arial,Helvetica,sans-serif">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#131315"><tr><td align="center" style="padding:24px 12px">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;table-layout:fixed;background:#1c1b1d;border:1px solid #363338;border-radius:16px">
|
||||
<tr><td style="padding:32px 24px 8px;color:#c7bdff;font-size:12px;letter-spacing:2px;font-weight:bold">MAGENT <span style="color:#918b98;letter-spacing:0">/ {esc(kicker)}</span></td></tr>
|
||||
<tr><td style="padding:12px 24px"><h1 style="margin:0 0 16px;font-size:32px;line-height:1.2;color:#f3eef6">{esc(title)}</h1><p style="margin:0;color:#bdb6c3;font-size:15px;line-height:1.7;overflow-wrap:anywhere">{esc(intro)}</p></td></tr>
|
||||
<tr><td style="padding:12px 24px">{content}</td></tr>
|
||||
<tr><td style="padding:20px 24px 32px"><a href="{esc(url, quote=True)}" style="display:inline-block;padding:15px 22px;border-radius:8px;background:#c7bdff;color:#211b30;text-decoration:none;font-size:14px;font-weight:bold">{esc(action)} ↗</a></td></tr>
|
||||
</table><table role="presentation" width="600" style="width:100%;max-width:600px"><tr><td style="padding:22px 18px;color:#a69fac;font-size:12px;line-height:1.7;text-align:center">{footer}</td></tr></table>
|
||||
</td></tr></table></body></html>'''
|
||||
|
||||
|
||||
def render_confirmation(username: str, url: str) -> dict:
|
||||
title = "Your month, delivered."
|
||||
intro = f"Hi {username}, confirm this email address to receive personal viewing reports from Magent. You choose whether to request them yourself or also receive automatic monthly emails."
|
||||
text = f"{intro}\n\nConfirm email recaps: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email. No viewing history will be emailed until you confirm."
|
||||
body = document(title=title, intro=intro,
|
||||
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">Minutes watched, movies, episodes, your longest run and requests — with a link to your full monthly report.</p>',
|
||||
action="Confirm email recaps", url=url,
|
||||
footer="This link expires in 24 hours. If you did not request this, ignore this email.<br>No viewing history will be emailed until you confirm.")
|
||||
return {"subject": "Confirm your Magent email recaps", "body_text": text, "body_html": body}
|
||||
|
||||
|
||||
def render_recap(report: dict, username: str, public_url: str, unsubscribe_url: str, *, test: bool = False, requested: bool = False) -> dict:
|
||||
esc = html.escape
|
||||
month = month_label(report["month"])
|
||||
previous = month_label(report["comparison_month"])
|
||||
if report.get('is_partial'):
|
||||
month += ' so far'
|
||||
previous += ' (same elapsed period, capped at month end)' if report.get('comparison_capped') else ' (same elapsed period)'
|
||||
summary = report["summary"]
|
||||
metrics = (("Minutes watched", "minutes", summary["minutes"]), ("Movies played", "movies", summary["movies"]),
|
||||
("Episodes played", "episodes", summary["episodes"]), ("Requests made", "requests", report["requests"]["total"]))
|
||||
cells, lines = [], []
|
||||
for label, key, value in metrics:
|
||||
change = report["changes"][key]
|
||||
difference = change["difference"]
|
||||
comparison = ("No change" if difference == 0 else f"{'+' if difference > 0 else '−'}{number(abs(difference))}")
|
||||
if change["percent"] is not None and difference:
|
||||
comparison += f" ({'+' if difference > 0 else '−'}{abs(change['percent']):g}%)"
|
||||
comparison += f" from {previous}"
|
||||
lines.append(f"{label}: {number(value)}. {comparison}.")
|
||||
cells.append(f'<td width="50%" valign="top" style="padding:16px 10px;border-bottom:1px solid #363338"><span style="color:#bdb6c3;font-size:12px">{label}</span><br><strong style="display:block;margin:10px 0;color:#e0d8ff;font-size:30px">{number(value)}</strong><span style="color:#a69fac;font-size:11px;line-height:1.6">{esc(comparison)}</span></td>')
|
||||
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"> </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>'
|
||||
report_url = f"{public_url}/insights/reports?month={report['month']}"
|
||||
intro = f"Hi {username}, here’s your {month} in viewing. A little look back at the stories you spent time with."
|
||||
footer = f'You enabled personal report emails from Magent.<br>Based on retained Jellystat history. Calendar months use UTC; request statuses are current.<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from recaps</a> · <a href="{esc(public_url + "/profile#monthly-recaps", quote=True)}" style="color:#c7bdff">Email preferences</a>'
|
||||
if requested:
|
||||
intro = 'You requested this report. ' + intro
|
||||
if test:
|
||||
intro = "This is your test recap. " + intro
|
||||
body = document(title=month, intro=intro, content=content, action="Explore your full report", url=report_url, footer=footer)
|
||||
text = '\n'.join([intro, '', *lines, '', habit, '', 'Most watched:',
|
||||
*(f"{item['title']}: {number(item['minutes'])} minutes" for item in top), '',
|
||||
f"Your full report: {report_url}", '', 'Based on retained Jellystat history. Calendar months use UTC; request statuses are current.',
|
||||
f"Unsubscribe from recaps: {unsubscribe_url}", f"Email preferences: {public_url}/profile#monthly-recaps"])
|
||||
return {"subject": f"{'[Test] ' if test else ''}Your {month} in viewing · Magent", "body_text": text, "body_html": body}
|
||||
|
||||
|
||||
def send_email(recipient: str, rendered: dict, message_id: str, before_data=lambda: None) -> None:
|
||||
"""Return only after SMTP accepts DATA. Never retry an ambiguous DATA disconnect.
|
||||
|
||||
A stable Message-ID aids diagnosis; it is not an SMTP deduplication guarantee.
|
||||
See RFC 5321 §4.5.3.2.6 and Python's smtplib exception definitions.
|
||||
"""
|
||||
runtime = get_runtime_settings()
|
||||
sender = valid_email(runtime.magent_notify_email_from_address)
|
||||
if not sender or not valid_email(recipient):
|
||||
raise DeliveryError("failed", "A valid sender and recipient email are required.")
|
||||
message = EmailMessage(policy=SMTP_POLICY)
|
||||
message["From"] = formataddr((str(runtime.magent_notify_email_from_name or "Magent").replace('\r', '').replace('\n', ''), sender))
|
||||
message["To"], message["Subject"] = recipient, rendered["subject"]
|
||||
message["Date"], message["Message-ID"] = formatdate(localtime=False), message_id
|
||||
message["Auto-Submitted"], message["X-Auto-Response-Suppress"] = "auto-generated", "All"
|
||||
message.set_content(rendered["body_text"])
|
||||
message.add_alternative(rendered["body_html"], subtype="html")
|
||||
html_part = message.get_payload()[-1]
|
||||
for attachment in rendered.get('inline_images', []):
|
||||
html_part.add_related(
|
||||
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:
|
||||
kwargs = {"timeout": 30, "local_hostname": sender.split('@', 1)[1]}
|
||||
if runtime.magent_notify_email_use_ssl:
|
||||
smtp = smtplib.SMTP_SSL(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port,
|
||||
context=ssl.create_default_context(), **kwargs)
|
||||
else:
|
||||
smtp = smtplib.SMTP(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port, **kwargs)
|
||||
smtp.ehlo_or_helo_if_needed()
|
||||
if runtime.magent_notify_email_use_tls and not runtime.magent_notify_email_use_ssl:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.ehlo()
|
||||
if runtime.magent_notify_email_smtp_username:
|
||||
smtp.login(runtime.magent_notify_email_smtp_username, runtime.magent_notify_email_smtp_password)
|
||||
code, reply = smtp.mail(sender)
|
||||
if code != 250:
|
||||
raise smtplib.SMTPResponseException(code, reply)
|
||||
code, reply = smtp.rcpt(recipient)
|
||||
if code not in (250, 251):
|
||||
raise smtplib.SMTPResponseException(code, reply)
|
||||
before_data()
|
||||
stage = "data"
|
||||
code, reply = smtp.data(payload)
|
||||
if code != 250:
|
||||
raise smtplib.SMTPDataError(code, reply)
|
||||
stage = "accepted"
|
||||
except smtplib.SMTPResponseException as exc:
|
||||
state = "retry" if 400 <= exc.smtp_code < 500 else "failed"
|
||||
raise DeliveryError(state, f"Mail server returned SMTP {exc.smtp_code}.") from exc
|
||||
except (ssl.SSLError, smtplib.SMTPNotSupportedError, UnicodeError, ValueError) as exc:
|
||||
raise DeliveryError("failed", "Check the SMTP security and sender settings.") from exc
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
state = "unknown" if stage == "data" else "retry"
|
||||
detail = "Mail server acceptance is unknown; check its logs before taking further action." if state == "unknown" else "Could not reach or finish connecting to the mail server."
|
||||
raise DeliveryError(state, detail) from exc
|
||||
finally:
|
||||
if smtp:
|
||||
# A failed QUIT after a 250 DATA response must not turn an accepted email into a retry.
|
||||
with suppress(Exception):
|
||||
smtp.quit()
|
||||
with suppress(Exception):
|
||||
smtp.close()
|
||||
|
||||
|
||||
def message_id(delivery_id: str, public_url: str) -> str:
|
||||
host = urlsplit(public_url).hostname or "magent.local"
|
||||
return f"<magent-recap-{delivery_id}@{host}>"
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Durable consent, schedule and delivery records for personal email recaps."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
import uuid
|
||||
from contextlib import closing, contextmanager
|
||||
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:
|
||||
for statement in (
|
||||
"""CREATE TABLE IF NOT EXISTS email_recap_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 0,
|
||||
day INTEGER NOT NULL DEFAULT 2, hour INTEGER NOT NULL DEFAULT 9,
|
||||
public_url TEXT NOT NULL DEFAULT '', next_send_at REAL)""",
|
||||
"INSERT OR IGNORE INTO email_recap_settings (id) VALUES (1)",
|
||||
"""CREATE TABLE IF NOT EXISTS email_recap_subscriptions (
|
||||
user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
|
||||
identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
|
||||
confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
|
||||
confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
|
||||
"""CREATE TABLE IF NOT EXISTS email_recap_deliveries (
|
||||
id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
|
||||
month TEXT NOT NULL, kind TEXT NOT NULL, email TEXT NOT NULL,
|
||||
subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
|
||||
claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_email_recap_queue ON email_recap_deliveries (state, next_attempt_at)",
|
||||
"""CREATE TRIGGER IF NOT EXISTS email_recap_account_changed AFTER UPDATE OF email, is_blocked ON users
|
||||
WHEN LOWER(TRIM(COALESCE(NEW.email, ''))) != LOWER(TRIM(COALESCE(OLD.email, '')))
|
||||
OR NEW.is_blocked = 1
|
||||
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
|
||||
confirmed_at = NULL WHERE user_id = NEW.id; END""",
|
||||
"""CREATE TRIGGER IF NOT EXISTS email_recap_account_deleted AFTER DELETE ON users
|
||||
BEGIN DELETE FROM email_recap_subscriptions WHERE user_id = OLD.id;
|
||||
UPDATE email_recap_deliveries SET state = 'cancelled', detail = 'Account removed.'
|
||||
WHERE user_id = OLD.id AND state IN ('queued', 'retry', 'preparing'); END""",
|
||||
"""CREATE TRIGGER IF NOT EXISTS email_recap_identity_changed AFTER UPDATE ON jellyfin_user_links
|
||||
WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source
|
||||
OR NEW.local_user_id != OLD.local_user_id
|
||||
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
|
||||
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
|
||||
"""CREATE TRIGGER IF NOT EXISTS email_recap_identity_deleted AFTER DELETE ON jellyfin_user_links
|
||||
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
|
||||
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
|
||||
):
|
||||
conn.execute(statement)
|
||||
columns = {row[1] for row in conn.execute('PRAGMA table_info(email_recap_subscriptions)')}
|
||||
if 'automatic_monthly' not in columns:
|
||||
conn.execute('ALTER TABLE email_recap_subscriptions ADD COLUMN automatic_monthly INTEGER NOT NULL DEFAULT 1')
|
||||
|
||||
|
||||
@contextmanager
|
||||
def transaction():
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
yield conn
|
||||
|
||||
|
||||
def read_one(sql: str, args=()) -> dict | None:
|
||||
with closing(db._connect()) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(sql, args).fetchone()
|
||||
return dict(row) if row else 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"}
|
||||
|
||||
|
||||
def next_due(now: datetime, day: int, hour: int) -> datetime:
|
||||
due = shift_month(now, 0).replace(day=day, hour=hour)
|
||||
return due if due > now else shift_month(now, 1).replace(day=day, hour=hour)
|
||||
|
||||
|
||||
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"))
|
||||
due = old["next_send_at"]
|
||||
if not values["enabled"]:
|
||||
due = None
|
||||
elif not old["enabled"] or changed:
|
||||
due = next_due(now, values["day"], values["hour"]).timestamp()
|
||||
conn.execute("UPDATE email_recap_settings SET enabled=?, day=?, hour=?, public_url=?, next_send_at=? WHERE id=1",
|
||||
(values["enabled"], values["day"], values["hour"], values["public_url"], due))
|
||||
if not values["enabled"] or changed:
|
||||
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Schedule paused or changed.', updated_at=?
|
||||
WHERE kind='scheduled' AND state IN ('queued', 'retry', 'preparing')""", (now.timestamp(),))
|
||||
return settings()
|
||||
|
||||
|
||||
def subscription(user_id: int) -> dict | None:
|
||||
return read_one("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user_id,))
|
||||
|
||||
|
||||
def disable(user_id: int) -> None:
|
||||
with transaction() as conn:
|
||||
conn.execute("UPDATE email_recap_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
|
||||
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Email recaps turned off.'
|
||||
WHERE user_id=? AND state IN ('queued', 'retry', 'preparing')""", (user_id,))
|
||||
|
||||
|
||||
def request_confirmation(user: dict, source: str, identity: str, now: float, automatic_monthly: bool = True) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
with transaction() as conn:
|
||||
old = conn.execute("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user["id"],)).fetchone()
|
||||
if old and old["requested_at"] > now - 300:
|
||||
raise ValueError("Please wait five minutes before requesting another confirmation email.")
|
||||
conn.execute("""INSERT INTO email_recap_subscriptions
|
||||
(user_id, state, email, identity_source, identity_id, version, confirmation_hash,
|
||||
confirmation_expires, requested_at, confirmed_at, unsubscribe_token)
|
||||
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, NULL, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET state='pending', email=excluded.email,
|
||||
identity_source=excluded.identity_source, identity_id=excluded.identity_id, version=excluded.version,
|
||||
confirmation_hash=excluded.confirmation_hash, confirmation_expires=excluded.confirmation_expires,
|
||||
requested_at=excluded.requested_at, confirmed_at=NULL, unsubscribe_token=excluded.unsubscribe_token""",
|
||||
(user["id"], user["email"].strip(), source, identity, uuid.uuid4().hex,
|
||||
hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
|
||||
conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (automatic_monthly, user['id']))
|
||||
return token
|
||||
|
||||
|
||||
def token_subscription(token: str, action: str) -> dict | None:
|
||||
if action == "confirm":
|
||||
return read_one("SELECT * FROM email_recap_subscriptions WHERE confirmation_hash=?",
|
||||
(hashlib.sha256(token.encode()).hexdigest(),))
|
||||
return read_one("SELECT * FROM email_recap_subscriptions WHERE unsubscribe_token=?", (token,))
|
||||
|
||||
|
||||
def confirm(sub: dict, now: float) -> bool:
|
||||
with transaction() as conn:
|
||||
# Recheck address and blocked state in the same transaction as the consent write.
|
||||
result = conn.execute("""UPDATE email_recap_subscriptions SET state='enabled', confirmed_at=?, confirmation_hash=NULL
|
||||
WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
|
||||
AND EXISTS (SELECT 1 FROM users WHERE users.id=user_id AND is_blocked=0
|
||||
AND LOWER(TRIM(users.email))=LOWER(TRIM(email_recap_subscriptions.email)))""",
|
||||
(now, sub["user_id"], sub["version"], now))
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def _enqueue(conn, sub: dict, month: str, kind: str, key: str, public_url: str, now: float) -> str:
|
||||
delivery_id = uuid.uuid4().hex
|
||||
conn.execute("""INSERT OR IGNORE INTO email_recap_deliveries
|
||||
(id, dedupe_key, user_id, month, kind, email, subscription_version, public_url,
|
||||
created_at, updated_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(delivery_id, key, sub["user_id"], month, kind, sub["email"], sub["version"], public_url, now, now, now))
|
||||
return conn.execute("SELECT id FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()[0]
|
||||
|
||||
|
||||
def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: float, kind: str = "test") -> str:
|
||||
key = f"{kind}:{sub['user_id']}:{request_id}"
|
||||
with transaction() as conn:
|
||||
existing = conn.execute("SELECT id,month,subscription_version FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()
|
||||
if existing:
|
||||
if existing['month'] != month or existing['subscription_version'] != sub['version']:
|
||||
raise ValueError('This send request was already used. Refresh before requesting another report.')
|
||||
return existing[0]
|
||||
recent = conn.execute("SELECT 1 FROM email_recap_deliveries WHERE user_id=? AND kind IN ('test','on_demand') AND created_at>?",
|
||||
(sub["user_id"], now - 300)).fetchone()
|
||||
if recent:
|
||||
raise ValueError("Please wait five minutes between report emails.")
|
||||
return _enqueue(conn, sub, month, kind, key, public_url, now)
|
||||
|
||||
|
||||
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.
|
||||
due = shift_month(now, 0).replace(day=config["day"], hour=config["hour"])
|
||||
if due > now:
|
||||
due = shift_month(now, -1).replace(day=config["day"], hour=config["hour"])
|
||||
month = shift_month(due, -1).strftime("%Y-%m")
|
||||
subs = conn.execute("SELECT * FROM email_recap_subscriptions WHERE state='enabled' AND automatic_monthly=1 AND confirmed_at<=?", (due.timestamp(),)).fetchall()
|
||||
before = conn.total_changes
|
||||
for sub in subs:
|
||||
_enqueue(conn, dict(sub), month, "scheduled", f"scheduled:{sub['user_id']}:{month}", config["public_url"], now.timestamp())
|
||||
count = conn.total_changes - before
|
||||
conn.execute("UPDATE email_recap_settings SET next_send_at=? WHERE id=1",
|
||||
(next_due(now, config["day"], config["hour"]).timestamp(),))
|
||||
return count
|
||||
|
||||
|
||||
def claim_delivery(now: float) -> dict | None:
|
||||
with transaction() as conn:
|
||||
return email_queue.claim(conn, "email_recap_deliveries", now)
|
||||
|
||||
|
||||
def begin_sending(delivery: dict, now: float) -> bool:
|
||||
with transaction() as conn:
|
||||
# Consent may have changed while the report or SMTP connection was being prepared.
|
||||
result = conn.execute("""UPDATE email_recap_deliveries SET state='sending', updated_at=?, lease_until=?
|
||||
WHERE id=? AND claim=? AND state='preparing'
|
||||
AND EXISTS (SELECT 1 FROM email_recap_subscriptions s JOIN users u ON u.id=s.user_id
|
||||
JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
|
||||
WHERE s.user_id=email_recap_deliveries.user_id AND s.state='enabled'
|
||||
AND s.version=email_recap_deliveries.subscription_version AND u.is_blocked=0
|
||||
AND (email_recap_deliveries.kind!='scheduled' OR s.automatic_monthly=1)
|
||||
AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
|
||||
AND EXISTS (SELECT 1 FROM email_recap_settings c WHERE c.id=1 AND c.public_url=email_recap_deliveries.public_url
|
||||
AND (email_recap_deliveries.kind IN ('test','on_demand') OR c.enabled=1))""", (now, now + 1800, delivery["id"], delivery["claim"]))
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def finish(delivery: dict, state: str, detail: str, now: float, delay: int = 0) -> None:
|
||||
with transaction() as conn:
|
||||
email_queue.finish(conn, "email_recap_deliveries", delivery, state, detail, now, delay)
|
||||
|
||||
|
||||
def history(limit: int = 50, offset: int = 0) -> dict:
|
||||
with closing(db._connect()) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute("""SELECT d.id, d.month, d.kind, d.email, d.state, d.attempts, d.created_at, d.updated_at,
|
||||
d.next_attempt_at, d.detail, u.username FROM email_recap_deliveries d LEFT JOIN users u ON u.id=d.user_id
|
||||
ORDER BY d.created_at DESC, d.id LIMIT ? OFFSET ?""", (limit, offset)).fetchall()
|
||||
total = conn.execute("SELECT COUNT(*) FROM email_recap_deliveries").fetchone()[0]
|
||||
subscribers = conn.execute("SELECT COUNT(*) FROM email_recap_subscriptions WHERE state='enabled'").fetchone()[0]
|
||||
return {"deliveries": [dict(row) for row in rows], "total": total, "subscribers": subscribers}
|
||||
|
||||
|
||||
def set_automatic(user_id: int, enabled: bool):
|
||||
with transaction() as conn:
|
||||
conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (enabled, user_id))
|
||||
if not enabled:
|
||||
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled',detail='Automatic monthly emails turned off.'
|
||||
WHERE user_id=? AND kind='scheduled' AND state IN ('queued','retry','preparing')""", (user_id,))
|
||||
|
||||
|
||||
def personal_history(user_id: int) -> list[dict]:
|
||||
with closing(db._connect()) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
return [dict(row) for row in conn.execute("""SELECT id,month,kind,state,created_at,detail
|
||||
FROM email_recap_deliveries WHERE user_id=? ORDER BY created_at DESC,id DESC LIMIT 5""", (user_id,))]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Explicit original-language requests without changing shared quality defaults."""
|
||||
import asyncio
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
_profile_lock = asyncio.Lock()
|
||||
_prefix = "Magent Original "
|
||||
|
||||
|
||||
def language_info(details):
|
||||
code = str(details.get("originalLanguage") or details.get("original_language") or "").lower()
|
||||
if not re.fullmatch(r"[a-z]{2}", code) or code in {"en", "xx", "zz"}:
|
||||
return None
|
||||
return {"code": code}
|
||||
|
||||
|
||||
def profile_body(profile):
|
||||
return {key: copy.deepcopy(value) for key, value in profile.items() if key not in {"id", "name"}}
|
||||
|
||||
|
||||
def profile_name(body):
|
||||
return _prefix + hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def is_original_profile(profile):
|
||||
return ((profile.get("language") or {}).get("id") == -2
|
||||
and profile.get("name") == profile_name(profile_body(profile)))
|
||||
|
||||
|
||||
async def original_profile(client, default_id):
|
||||
# Reuse immutable copies; never edit a profile already used by other titles.
|
||||
async with _profile_lock:
|
||||
try:
|
||||
profiles = await client.get_quality_profiles()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, "Radarr could not load the language profile. Try again.") from exc
|
||||
if not isinstance(profiles, list):
|
||||
raise HTTPException(502, "Radarr returned invalid quality profiles.")
|
||||
default = next((p for p in profiles if p.get("id") == default_id), None)
|
||||
if not default:
|
||||
raise HTTPException(409, "The default quality profile changed. Reload the request.")
|
||||
body = profile_body(default)
|
||||
body["language"] = {"id": -2, "name": "Original"}
|
||||
name = profile_name(body)
|
||||
match = next((p for p in profiles if p.get("name") == name and profile_body(p) == body), None)
|
||||
if match:
|
||||
return match["id"]
|
||||
try:
|
||||
result = await client.post("/api/v3/qualityprofile", payload={**body, "name": name})
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.") from exc
|
||||
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.'}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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 starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from ..config import settings
|
||||
from ..installation_origin import managed_runtime
|
||||
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 managed_runtime():
|
||||
# The operator confirms this address using the first-install token.
|
||||
# No localhost fallback remains trusted after a managed installation.
|
||||
return candidate == _origin(magent_public_url(), configured_url=True)
|
||||
if candidate == _origin(str(settings.cors_allow_origin or "").rstrip("/")):
|
||||
return True
|
||||
return candidate == _origin(magent_public_url(), configured_url=True)
|
||||
|
||||
|
||||
def can_claim_initial_origin() -> bool:
|
||||
if not managed_runtime() or magent_public_url():
|
||||
return False
|
||||
from .setup import get_public_setup_status
|
||||
return get_public_setup_status()["needs_admin"]
|
||||
|
||||
|
||||
class ConfiguredOriginCORSMiddleware(CORSMiddleware):
|
||||
"""Keep CORS response/preflight policy aligned with managed origin checks."""
|
||||
|
||||
def is_allowed_origin(self, origin: str) -> bool:
|
||||
if managed_runtime():
|
||||
return is_allowed_request_origin(origin)
|
||||
return super().is_allowed_origin(origin)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Persistent, operator-authorized first-install setup.
|
||||
|
||||
Initialize the marker before the main schema: an existing users table identifies
|
||||
an upgraded installation, while a new database must finish the setup wizard.
|
||||
The marker and first administrator are protected by SQLite write transactions.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hmac
|
||||
from math import ceil
|
||||
from time import time
|
||||
from typing import Literal
|
||||
|
||||
from .. import db
|
||||
from ..config import settings
|
||||
from ..security import hash_password, validate_password_policy
|
||||
from ..installation_origin import normalize_application_origin
|
||||
|
||||
|
||||
SetupStep = Literal["administrator", "apps", "preferences", "review"]
|
||||
SETUP_STEPS = ("administrator", "apps", "preferences", "review")
|
||||
BOOTSTRAP_WINDOW_SECONDS = 15 * 60
|
||||
BOOTSTRAP_IP_ATTEMPTS = 5
|
||||
BOOTSTRAP_GLOBAL_ATTEMPTS = 30
|
||||
|
||||
|
||||
class SetupUnavailableError(ValueError):
|
||||
"""Setup has finished, or another administrator already exists."""
|
||||
|
||||
|
||||
class InvalidSetupTokenError(ValueError):
|
||||
"""The operator's setup token was absent or did not match."""
|
||||
|
||||
|
||||
def initialize_setup_state() -> None:
|
||||
"""Run once before init_db; subsequent calls preserve progress."""
|
||||
with db._connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
existing_install = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'"
|
||||
).fetchone() is not None
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS installation_setup (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
completed INTEGER NOT NULL CHECK (completed IN (0, 1)),
|
||||
step TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS installation_setup_attempts (
|
||||
scope TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
occurred_at REAL NOT NULL
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO installation_setup (id, completed, step, completed_at)
|
||||
VALUES (1, ?, ?, ?)""",
|
||||
(
|
||||
int(existing_install),
|
||||
"review" if existing_install else "administrator",
|
||||
datetime.now(timezone.utc).isoformat() if existing_install else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_setup_state() -> dict:
|
||||
with db._connect() as conn:
|
||||
# Old databases and isolated callers without startup initialization are
|
||||
# already installed. A missing marker must never open public bootstrap.
|
||||
table = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'installation_setup'"
|
||||
).fetchone()
|
||||
row = conn.execute(
|
||||
"SELECT completed, step, completed_at FROM installation_setup WHERE id = 1"
|
||||
).fetchone() if table else None
|
||||
if row is None:
|
||||
return {"completed": True, "step": "review", "completed_at": None}
|
||||
return {"completed": bool(row[0]), "step": row[1], "completed_at": row[2]}
|
||||
|
||||
|
||||
def is_setup_required() -> bool:
|
||||
return not get_setup_state()["completed"]
|
||||
|
||||
|
||||
def get_public_setup_status() -> dict:
|
||||
required = is_setup_required()
|
||||
return {"setup_required": required, "needs_admin": required and not db.has_admin_user()}
|
||||
|
||||
|
||||
def setup_token_configured() -> bool:
|
||||
"""Reject missing values and obvious examples, without claiming to measure entropy."""
|
||||
token = str(getattr(settings, "setup_token", "") or "").strip()
|
||||
placeholder = token.casefold().replace("_", "-")
|
||||
return (
|
||||
len(token) >= 32
|
||||
and len(set(token)) > 1
|
||||
and not placeholder.startswith(("replace-with-", "replace-me", "change-me", "changeme", "your-setup-token"))
|
||||
)
|
||||
|
||||
|
||||
def consume_bootstrap_attempt(client_ip: str) -> int | None:
|
||||
"""Atomically reserve one attempt; return Retry-After when limited.
|
||||
|
||||
The IP is keyed using the existing HMAC helper, never stored in clear text.
|
||||
A shared cap limits distributed attempts and expensive password hashing.
|
||||
"""
|
||||
now = time()
|
||||
cutoff = now - BOOTSTRAP_WINDOW_SECONDS
|
||||
limits = (
|
||||
("setup-ip", db._rate_limit_key_hash(client_ip), BOOTSTRAP_IP_ATTEMPTS),
|
||||
("setup-global", db._rate_limit_key_hash("bootstrap"), BOOTSTRAP_GLOBAL_ATTEMPTS),
|
||||
)
|
||||
with db._connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute(
|
||||
"DELETE FROM installation_setup_attempts WHERE occurred_at < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
retry_after = 0
|
||||
for scope, key, maximum in limits:
|
||||
count, oldest = conn.execute(
|
||||
"""SELECT COUNT(*), MIN(occurred_at) FROM installation_setup_attempts
|
||||
WHERE scope = ? AND key_hash = ? AND occurred_at >= ?""",
|
||||
(scope, key, cutoff),
|
||||
).fetchone()
|
||||
if count >= maximum:
|
||||
retry_after = max(retry_after, ceil(BOOTSTRAP_WINDOW_SECONDS - (now - oldest)), 1)
|
||||
if retry_after:
|
||||
return retry_after
|
||||
conn.executemany(
|
||||
"INSERT INTO installation_setup_attempts (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
|
||||
[(scope, key, now) for scope, key, _ in limits],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def bootstrap_administrator(setup_token: str, username: str, password: str, *, application_url: str | None = None) -> None:
|
||||
"""Claim fresh setup exactly once using the deployment's setup token."""
|
||||
expected = str(getattr(settings, "setup_token", "") or "")
|
||||
if not setup_token_configured() or not hmac.compare_digest(
|
||||
setup_token.encode("utf-8"), expected.encode("utf-8")
|
||||
):
|
||||
raise InvalidSetupTokenError("Invalid setup token.")
|
||||
username = username.strip()
|
||||
if not username or len(username) > 100 or any(
|
||||
character.isspace() or ord(character) < 32 or ord(character) == 127 for character in username
|
||||
):
|
||||
raise ValueError("Username must contain 1 to 100 characters without spaces or control characters.")
|
||||
if len(password) > 1024:
|
||||
raise ValueError("Password must contain no more than 1024 characters.")
|
||||
password = validate_password_policy(password)
|
||||
if application_url is not None:
|
||||
application_url = normalize_application_origin(application_url)
|
||||
if not is_setup_required() or db.has_admin_user():
|
||||
raise SetupUnavailableError("Initial administrator setup is no longer available.")
|
||||
|
||||
password_hash = hash_password(password)
|
||||
with db._connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
setup = conn.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
|
||||
admin = conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
|
||||
if setup is None or setup[0] or admin:
|
||||
raise SetupUnavailableError("Initial administrator setup is no longer available.")
|
||||
if any(str(row[0]).strip().casefold() == username.casefold() for row in conn.execute("SELECT username FROM users")):
|
||||
raise SetupUnavailableError("That username already exists.")
|
||||
conn.execute(
|
||||
"""INSERT INTO users (username, password_hash, role, auth_provider, created_at)
|
||||
VALUES (?, ?, 'admin', 'local', ?)""",
|
||||
(username, password_hash, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1")
|
||||
if application_url is not None:
|
||||
conn.execute(
|
||||
"""INSERT INTO settings (key, value, updated_at) VALUES ('magent_application_url', ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""",
|
||||
(application_url, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
|
||||
|
||||
def update_setup_step(step: SetupStep) -> dict:
|
||||
if step not in SETUP_STEPS:
|
||||
raise ValueError("Invalid setup step.")
|
||||
if not is_setup_required():
|
||||
return get_setup_state()
|
||||
with db._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE installation_setup SET step = ? WHERE id = 1 AND completed = 0", (step,)
|
||||
)
|
||||
return get_setup_state()
|
||||
|
||||
|
||||
def complete_setup() -> dict:
|
||||
if not is_setup_required():
|
||||
return get_setup_state()
|
||||
with db._connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if not conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone():
|
||||
raise SetupUnavailableError("Create an administrator before completing setup.")
|
||||
conn.execute(
|
||||
"""UPDATE installation_setup SET completed = 1, step = 'review', completed_at = ?
|
||||
WHERE id = 1 AND completed = 0""",
|
||||
(datetime.now(timezone.utc).isoformat(),),
|
||||
)
|
||||
return get_setup_state()
|
||||
+1143
-102
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..db import get_setting, set_setting
|
||||
from ..db import get_setting, set_setting, delete_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -89,6 +89,33 @@ def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int
|
||||
return candidate_to_id
|
||||
|
||||
|
||||
def find_matching_jellyseerr_user(
|
||||
identifier: str, users: List[Dict[str, Any]]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
target_handles = set(_normalized_handles(identifier))
|
||||
if not target_handles:
|
||||
return None
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
for key in ("username", "email", "displayName", "name"):
|
||||
if target_handles.intersection(_normalized_handles(user.get(key))):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if not isinstance(user, dict):
|
||||
return None
|
||||
value = user.get("email")
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
candidate = value.strip()
|
||||
if not candidate or "@" not in candidate:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def match_jellyseerr_user_id(
|
||||
username: str, candidate_map: Dict[str, int]
|
||||
) -> Optional[int]:
|
||||
@@ -114,7 +141,7 @@ def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, A
|
||||
}
|
||||
)
|
||||
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
|
||||
logger.debug("Cached Jellyseerr users: %s", len(normalized))
|
||||
logger.debug("Cached Seerr users: %s", len(normalized))
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -142,3 +169,17 @@ def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any
|
||||
|
||||
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
||||
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
|
||||
|
||||
|
||||
def clear_user_import_caches() -> Dict[str, int]:
|
||||
cleared = 0
|
||||
for key in (
|
||||
JELLYSEERR_CACHE_KEY,
|
||||
JELLYSEERR_CACHE_AT_KEY,
|
||||
JELLYFIN_CACHE_KEY,
|
||||
JELLYFIN_CACHE_AT_KEY,
|
||||
):
|
||||
delete_setting(key)
|
||||
cleared += 1
|
||||
logger.debug("Cleared user import cache keys: %s", cleared)
|
||||
return {"settingsKeysCleared": cleared}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
coverage==7.16.1
|
||||
pip-audit==2.10.1
|
||||
ruff==0.16.8
|
||||
@@ -1,9 +1,12 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.30.6
|
||||
httpx==0.27.2
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
fastapi==0.134.0
|
||||
uvicorn==0.41.0
|
||||
httpx==0.28.1
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.14.2
|
||||
PyJWT==2.13.0
|
||||
passlib==1.7.4
|
||||
python-multipart==0.0.9
|
||||
Pillow==10.4.0
|
||||
argon2-cffi==25.1.0
|
||||
cryptography==50.0.1
|
||||
python-multipart==0.0.31
|
||||
Pillow==12.3.0
|
||||
prometheus-client==0.22.1
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
||||
from contextlib import closing
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.auth import get_current_user
|
||||
from backend.app.config import settings
|
||||
from backend.app.routers import backups as backup_router
|
||||
from backend.app.services import backups
|
||||
|
||||
|
||||
PASSPHRASE = "test backup passphrase with spaces"
|
||||
|
||||
|
||||
class BackupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.database = self.root / "magent.db"
|
||||
for key, value in {
|
||||
"sqlite_path": str(self.database), "sqlite_journal_mode": "DELETE",
|
||||
"settings_encryption_key": Fernet.generate_key().decode(),
|
||||
"jwt_secret": "source-installation-signing-secret-for-backup-tests",
|
||||
"admin_username": "backup-admin", "admin_password": "a secure initial password",
|
||||
"jellyfin_api_key": "environment-integration-secret", "setup_token": "local-setup-token",
|
||||
"discord_webhook_url": "https://discord.example.invalid/api/webhooks/legacy-private-token",
|
||||
}.items():
|
||||
context = patch.object(settings, key, value)
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
context = patch.object(backups, "_assets_root", return_value=self.root / "assets")
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
db.init_db()
|
||||
db.set_setting("sonarr_api_key", "database-integration-secret")
|
||||
db.set_setting("site_login_message", "Restored configuration")
|
||||
db.set_setting("installation_setup", "complete")
|
||||
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||
conn.execute("INSERT INTO requests_cache(request_id,title,payload_json) VALUES (3580,'Suits','{}')")
|
||||
conn.execute(
|
||||
"INSERT INTO signup_invites(code,enabled,created_at,updated_at) VALUES ('sha256:existing-invite',1,'now','now')"
|
||||
)
|
||||
self.assets = self.root / "assets"
|
||||
(self.assets / "branding").mkdir(parents=True)
|
||||
(self.assets / "branding" / "logo.png").write_bytes(b"branding fixture")
|
||||
(self.assets / "artwork" / "tmdb" / "w342").mkdir(parents=True)
|
||||
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").write_bytes(b"cached fixture")
|
||||
|
||||
def export(self, include_cache=True):
|
||||
content, filename = backups.create_backup(PASSPHRASE, include_cache)
|
||||
self.assertTrue(filename.endswith(".magent-backup"))
|
||||
return content
|
||||
|
||||
def rewrite_archive(self, content, change):
|
||||
decrypted = backups._decrypt(content, PASSPHRASE)
|
||||
with zipfile.ZipFile(io.BytesIO(decrypted)) as archive:
|
||||
files = {entry.filename: archive.read(entry) for entry in archive.infolist()}
|
||||
change(files)
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w") as archive:
|
||||
for name, value in files.items():
|
||||
archive.writestr(name, value)
|
||||
return backups._encrypt(output.getvalue(), PASSPHRASE)
|
||||
|
||||
def test_round_trip_reencrypts_secrets_preserves_invites_and_restores_cache_on_restart(self):
|
||||
content = self.export()
|
||||
self.assertNotIn(b"database-integration-secret", content)
|
||||
self.assertNotIn(b"environment-integration-secret", content)
|
||||
original_auth_version = db.get_user_by_username("backup-admin")["auth_version"]
|
||||
db.set_setting("site_login_message", "Live data before restart")
|
||||
settings.settings_encryption_key = Fernet.generate_key().decode()
|
||||
settings.jwt_secret = "destination-installation-signing-secret-for-backup-tests"
|
||||
# Simulate a different host with different env-backed integration settings.
|
||||
settings.jellyfin_api_key = "destination-env-value"
|
||||
metadata = backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertTrue(metadata["include_cache"])
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Live data before restart")
|
||||
self.assertIsNotNone(backups.backup_status()["pending_restore"])
|
||||
staged_bytes = (self.database.parent / "backups" / "pending" / "database.sqlite3").read_bytes()
|
||||
self.assertNotIn(b"database-integration-secret", staged_bytes)
|
||||
self.assertNotIn(b"environment-integration-secret", staged_bytes)
|
||||
self.assertNotIn(b"legacy-private-token", staged_bytes)
|
||||
(self.assets / "branding" / "logo.png").write_bytes(b"changed logo")
|
||||
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").unlink()
|
||||
self.assertTrue(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
|
||||
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||
self.assertEqual(db.get_setting("jellyfin_api_key"), "environment-integration-secret")
|
||||
self.assertEqual(db.get_setting("discord_webhook_url"), "https://discord.example.invalid/api/webhooks/legacy-private-token")
|
||||
self.assertEqual(db.get_setting("installation_setup"), "complete")
|
||||
self.assertIsNone(db.get_setting("setup_token"))
|
||||
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"branding fixture")
|
||||
self.assertEqual((self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").read_bytes(), b"cached fixture")
|
||||
self.assertGreater(db.get_user_by_username("backup-admin")["auth_version"], original_auth_version)
|
||||
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||
self.assertEqual(conn.execute("SELECT title FROM requests_cache WHERE request_id=3580").fetchone(), ("Suits",))
|
||||
self.assertEqual(conn.execute("SELECT code FROM signup_invites").fetchone(), ("sha256:existing-invite",))
|
||||
self.assertTrue(conn.execute("SELECT value FROM settings WHERE key='sonarr_api_key'").fetchone()[0].startswith("enc:v1:"))
|
||||
status = backups.backup_status()
|
||||
self.assertIsNone(status["pending_restore"])
|
||||
self.assertEqual(status["last_restore"]["status"], "restored")
|
||||
self.assertTrue((self.database.parent / "backups" / status["last_restore"]["rollback_directory"] / "database.sqlite3").is_file())
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
|
||||
def test_wal_snapshot_contains_committed_uncheckpointed_rows(self):
|
||||
with closing(sqlite3.connect(self.database)) as writer:
|
||||
writer.execute("PRAGMA journal_mode=WAL")
|
||||
writer.execute("PRAGMA wal_autocheckpoint=0")
|
||||
writer.execute("UPDATE requests_cache SET title='Written in WAL' WHERE request_id=3580")
|
||||
writer.commit()
|
||||
self.assertTrue(Path(str(self.database) + "-wal").exists())
|
||||
content = self.export()
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertTrue(backups.apply_pending_restore())
|
||||
with closing(sqlite3.connect(self.database)) as restored:
|
||||
self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL")
|
||||
|
||||
def test_managed_restore_preserves_destination_application_origin(self):
|
||||
db.set_setting("magent_application_url", "https://source.example.test")
|
||||
content = self.export()
|
||||
db.set_setting("magent_application_url", "https://destination.example.test")
|
||||
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertTrue(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("magent_application_url"), "https://destination.example.test")
|
||||
|
||||
def test_manual_restore_retains_legacy_application_url_behavior(self):
|
||||
db.set_setting("magent_application_url", "https://source.example.test")
|
||||
content = self.export()
|
||||
db.set_setting("magent_application_url", "https://destination.example.test")
|
||||
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": ""}):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertTrue(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("magent_application_url"), "https://source.example.test")
|
||||
|
||||
def test_managed_restore_without_destination_origin_does_not_stage(self):
|
||||
content = self.export()
|
||||
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}), \
|
||||
patch("backend.app.services.public_urls.magent_public_url", return_value=""):
|
||||
with self.assertRaisesRegex(backups.BackupError, "destination application address"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_process_interruption_is_recovered_on_next_startup(self):
|
||||
class ProcessStopped(BaseException):
|
||||
pass
|
||||
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Value before interrupted restart")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with patch.object(backups, "_replace_assets", side_effect=ProcessStopped):
|
||||
with self.assertRaises(ProcessStopped):
|
||||
backups.apply_pending_restore()
|
||||
self.assertTrue((self.database.parent / "backups" / "restore-journal.json").exists())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Value before interrupted restart")
|
||||
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_crash_after_rollback_does_not_reapply_pending_restore(self):
|
||||
class ProcessStopped(BaseException):
|
||||
pass
|
||||
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Value to retain")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
replace_assets = backups._replace_assets
|
||||
remove_tree = backups.shutil.rmtree
|
||||
calls = 0
|
||||
|
||||
def fail_first_copy(source, target):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("failed apply")
|
||||
return replace_assets(source, target)
|
||||
|
||||
def interrupt_cleanup(path, *args, **kwargs):
|
||||
if Path(path).name == "pending":
|
||||
raise ProcessStopped()
|
||||
return remove_tree(path, *args, **kwargs)
|
||||
|
||||
with patch.object(backups, "_replace_assets", side_effect=fail_first_copy), \
|
||||
patch.object(backups.shutil, "rmtree", side_effect=interrupt_cleanup):
|
||||
with self.assertRaises(ProcessStopped):
|
||||
backups.apply_pending_restore()
|
||||
journal = json.loads((self.root / "backups" / "restore-journal.json").read_text())
|
||||
self.assertEqual(journal["phase"], "rolled_back")
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Value to retain")
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_missing_runtime_column_is_rejected_even_with_current_migration_version(self):
|
||||
directory = self.root / "schema-test"
|
||||
directory.mkdir()
|
||||
backups._extract_archive(backups._decrypt(self.export(), PASSPHRASE), directory)
|
||||
source = directory / "database.sqlite3"
|
||||
with closing(sqlite3.connect(source)) as conn, conn:
|
||||
conn.execute("ALTER TABLE users DROP COLUMN auto_search_enabled")
|
||||
with self.assertRaisesRegex(backups.BackupError, "missing database columns"):
|
||||
backups._validate_database(source)
|
||||
|
||||
def test_changed_encryption_key_since_staging_leaves_live_database_untouched(self):
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Current data")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
settings.settings_encryption_key = Fernet.generate_key().decode()
|
||||
with self.assertRaisesRegex(backups.BackupError, "configuration is invalid"):
|
||||
backups.apply_pending_restore()
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Current data")
|
||||
self.assertIsNotNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_excluding_disk_cache_keeps_database_cache_and_branding(self):
|
||||
with zipfile.ZipFile(io.BytesIO(backups._decrypt(self.export(False), PASSPHRASE))) as archive:
|
||||
self.assertIn("database.sqlite3", archive.namelist())
|
||||
self.assertIn("files/branding/logo.png", archive.namelist())
|
||||
self.assertFalse(any("artwork" in name for name in archive.namelist()))
|
||||
|
||||
def test_wrong_password_and_tampering_never_stage_or_touch_live_database(self):
|
||||
content = self.export()
|
||||
for bad_content, password in ((content, "incorrect password value"), (content[:-1] + bytes([content[-1] ^ 1]), PASSPHRASE)):
|
||||
with self.subTest(password=password):
|
||||
with self.assertRaisesRegex(backups.BackupError, "Incorrect passphrase or damaged"):
|
||||
backups.stage_restore(io.BytesIO(bad_content), password)
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||
|
||||
def test_path_traversal_unknown_files_and_checksum_failures_rejected(self):
|
||||
content = self.export()
|
||||
for name in ("../outside.txt", "/absolute.txt", "files/branding/../../../escape", "files/branding/script.py"):
|
||||
with self.subTest(name=name):
|
||||
malformed = self.rewrite_archive(content, lambda files: files.update({name: b"bad"}))
|
||||
with self.assertRaises(backups.BackupError):
|
||||
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
|
||||
malformed = self.rewrite_archive(content, lambda files: files.update({"files/branding/logo.png": b"tampered"}))
|
||||
with self.assertRaises(backups.BackupError):
|
||||
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
|
||||
self.assertFalse((self.root / "outside.txt").exists())
|
||||
|
||||
def test_size_limit_and_unsupported_schema_rejected(self):
|
||||
content = self.export()
|
||||
with patch.object(backups, "MAX_UPLOAD_BYTES", 16):
|
||||
with self.assertRaisesRegex(backups.BackupError, "upload limit"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with patch.object(backups, "MAX_EXPANDED_BYTES", 16):
|
||||
with self.assertRaisesRegex(backups.BackupError, "Expanded backup"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||
conn.execute("CREATE TRIGGER unsafe AFTER INSERT ON settings BEGIN DELETE FROM users; END")
|
||||
# Validate the original fixture to avoid executing the malicious trigger in export.
|
||||
with self.assertRaisesRegex(backups.BackupError, "unsupported database schema"):
|
||||
backups._validate_database(self.database)
|
||||
|
||||
def test_unsupported_compression_is_rejected_before_expansion(self):
|
||||
content = self.export()
|
||||
rewritten = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(backups._decrypt(content, PASSPHRASE))) as original:
|
||||
with zipfile.ZipFile(rewritten, "w", compression=zipfile.ZIP_BZIP2) as target:
|
||||
for entry in original.infolist():
|
||||
target.writestr(entry.filename, original.read(entry))
|
||||
with self.assertRaisesRegex(backups.BackupError, "unsafe archive entry"):
|
||||
backups.stage_restore(io.BytesIO(backups._encrypt(rewritten.getvalue(), PASSPHRASE)), PASSPHRASE)
|
||||
|
||||
def test_cancel_is_idempotent_and_does_not_change_database(self):
|
||||
content = self.export()
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with self.assertRaisesRegex(backups.BackupError, "already staged"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
backups.cancel_restore()
|
||||
backups.cancel_restore()
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||
|
||||
def test_failure_after_database_replacement_rolls_back_both_database_and_files(self):
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Keep this current value")
|
||||
(self.assets / "branding" / "logo.png").write_bytes(b"current logo")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
original = backups._replace_assets
|
||||
calls = 0
|
||||
|
||||
def fail_once(source, target):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("simulated interrupted copy")
|
||||
return original(source, target)
|
||||
|
||||
with patch.object(backups, "_replace_assets", side_effect=fail_once):
|
||||
with self.assertRaisesRegex(OSError, "interrupted copy"):
|
||||
backups.apply_pending_restore()
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Keep this current value")
|
||||
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"current logo")
|
||||
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
|
||||
def test_api_requires_admin_and_restore_confirmation(self):
|
||||
app = FastAPI()
|
||||
app.include_router(backup_router.router)
|
||||
with TestClient(app) as client:
|
||||
self.assertEqual(client.get("/admin/backups").status_code, 401)
|
||||
app.dependency_overrides[get_current_user] = lambda: {"username": "member", "role": "user"}
|
||||
self.assertEqual(client.get("/admin/backups").status_code, 403)
|
||||
self.assertEqual(client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}).status_code, 403)
|
||||
app.dependency_overrides[get_current_user] = lambda: {"username": "backup-admin", "role": "admin"}
|
||||
status = client.get("/admin/backups")
|
||||
self.assertEqual(status.status_code, 200)
|
||||
self.assertEqual(status.headers["cache-control"], "no-store")
|
||||
self.assertEqual(status.json()["max_expanded_bytes"], backups.MAX_EXPANDED_BYTES)
|
||||
response = client.post("/admin/backups/export", json={"passphrase": PASSPHRASE})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
rejected = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
|
||||
data={"passphrase": PASSPHRASE, "confirmation": "wrong"})
|
||||
self.assertEqual(rejected.status_code, 422)
|
||||
restored = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
|
||||
data={"passphrase": PASSPHRASE, "confirmation": "RESTORE"})
|
||||
self.assertEqual(restored.status_code, 202)
|
||||
self.assertTrue(restored.json()["restart_required"])
|
||||
self.assertEqual(client.delete("/admin/backups/restore").status_code, 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,158 @@
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app.config import settings
|
||||
from backend.app.models import NormalizedState, RequestType, Snapshot
|
||||
from backend.app.services import snapshot as snapshot_service
|
||||
from backend.app.services.collector_search import read_search_status, search_status
|
||||
|
||||
|
||||
def command(name="MoviesSearch", status="started", **body):
|
||||
return {"name": name, "status": status, "body": body}
|
||||
|
||||
|
||||
class CollectorSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_movie_search_is_scoped_to_the_movie(self):
|
||||
self.assertEqual(search_status([command(movieIds=[12])], RequestType.movie, 12), "searching")
|
||||
self.assertEqual(search_status([command(movieIds=[13])], RequestType.movie, 12), "idle")
|
||||
|
||||
def test_queued_search_and_running_search_priority(self):
|
||||
queued = command(status="queued", movieIds=[12])
|
||||
self.assertEqual(search_status([queued], RequestType.movie, 12), "queued")
|
||||
self.assertEqual(search_status([queued, command(movieIds=[12])], RequestType.movie, 12), "searching")
|
||||
|
||||
def test_terminal_commands_are_not_searching(self):
|
||||
for state in ["completed", "failed", "aborted", "cancelled", "orphaned", 2, 3, 4, 5, 6]:
|
||||
with self.subTest(state=state):
|
||||
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), "idle")
|
||||
ended = {**command(movieIds=[12]), "ended": "2026-09-06T00:00:00Z"}
|
||||
self.assertEqual(search_status([ended], RequestType.movie, 12), "idle")
|
||||
|
||||
def test_numeric_statuses(self):
|
||||
for state, expected in [(0, "queued"), (1, "searching")]:
|
||||
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), expected)
|
||||
|
||||
def test_series_and_season_searches(self):
|
||||
for name in ["SeriesSearch", "SeasonSearch"]:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(search_status([command(name, seriesId=12, seasonNumber=5)], RequestType.tv, 12), "searching")
|
||||
self.assertEqual(search_status([command(name, seriesId=13, seasonNumber=5)], RequestType.tv, 12), "idle")
|
||||
|
||||
def test_episode_search_uses_episode_ids_not_numbers(self):
|
||||
episodes = [{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9}]
|
||||
for ids, expected in [([109], "searching"), ([9], "idle"), ([110], "idle")]:
|
||||
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=ids)], RequestType.tv, 12, episodes), expected)
|
||||
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=[109])], RequestType.tv, 13, episodes), "idle")
|
||||
|
||||
def test_background_tasks_and_unscoped_searches_are_not_title_searches(self):
|
||||
for name in ["RssSync", "RefreshMovie", "RefreshSeries", "MissingEpisodeSearch", "MoviesSearch"]:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(search_status([command(name)], RequestType.movie, 12), "idle")
|
||||
|
||||
def test_empty_commands_are_idle_but_missing_response_is_unknown(self):
|
||||
self.assertEqual(search_status([], RequestType.movie, 12), "idle")
|
||||
for payload in [None, {}, {"error": "unavailable"}]:
|
||||
self.assertEqual(search_status(payload, RequestType.movie, 12), "unavailable")
|
||||
|
||||
async def test_check_is_read_only_with_a_short_timeout(self):
|
||||
client = SimpleNamespace(get=AsyncMock(return_value=[command(movieIds=[12])]))
|
||||
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "searching")
|
||||
client.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
|
||||
|
||||
async def test_service_failure_is_unknown_not_idle(self):
|
||||
client = SimpleNamespace(get=AsyncMock(side_effect=TimeoutError()))
|
||||
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "unavailable")
|
||||
|
||||
|
||||
class LibrarySearchPresentationTests(unittest.TestCase):
|
||||
def presentation(self, search="idle", *, media_type=RequestType.movie, available=0, missing=1,
|
||||
arr_state="added", download_state="not_started", jellyfin=False):
|
||||
snapshot = Snapshot(request_id="12", title="Example", request_type=media_type,
|
||||
state=NormalizedState.added_to_arr)
|
||||
return snapshot_service._build_presentation(
|
||||
snapshot, approved=True, arr_state=arr_state,
|
||||
arr_details={"search": {"state": search}, "availability": {
|
||||
"available": available, "missing": missing, "total": available + missing,
|
||||
}}, prowlarr_state="ok",
|
||||
download={"visible": download_state != "not_started", "state": download_state, "torrents": []},
|
||||
jellyfin_found=jellyfin, jellyfin_link=None,
|
||||
)
|
||||
|
||||
def stage(self, presentation, stage_id="library"):
|
||||
return next(stage for stage in presentation["pipeline"] if stage["id"] == stage_id)
|
||||
|
||||
def test_card_uses_actual_search_state(self):
|
||||
for state, badge, style in [("idle", "Not searching", "waiting"), ("searching", "Searching", "active"),
|
||||
("queued", "Search queued", "active"), ("unavailable", "Search unknown", "attention")]:
|
||||
with self.subTest(state=state):
|
||||
presentation = self.presentation(state)
|
||||
library = self.stage(presentation)
|
||||
self.assertEqual(library["stateLabel"], badge)
|
||||
self.assertEqual(library["state"], style)
|
||||
self.assertEqual(library["searchStatus"], state)
|
||||
self.assertEqual(self.stage(presentation, "search")["summary"], library["summary"])
|
||||
self.assertEqual(library["available"], 0)
|
||||
self.assertEqual(library["missing"], 1)
|
||||
|
||||
def test_partial_tv_retains_counts_and_search_activity(self):
|
||||
for state in ["idle", "searching", "queued", "unavailable"]:
|
||||
with self.subTest(state=state):
|
||||
presentation = self.presentation(state, media_type=RequestType.tv, available=22, missing=2, jellyfin=True)
|
||||
library = self.stage(presentation)
|
||||
self.assertEqual(library["state"], "partial")
|
||||
self.assertEqual(library["searchStatus"], state)
|
||||
self.assertIn("22 of 24 episodes collected", library["summary"])
|
||||
self.assertNotIn("is still looking", presentation["status"]["meaning"])
|
||||
|
||||
def test_collected_titles_dont_look_stuck_searching(self):
|
||||
for jellyfin in [True, False]:
|
||||
library = self.stage(self.presentation("idle", arr_state="available", available=1, missing=0, jellyfin=jellyfin))
|
||||
self.assertEqual(library["state"], "complete")
|
||||
self.assertIn("no search needed", library["summary"])
|
||||
|
||||
def test_download_has_its_own_state_without_claiming_searching(self):
|
||||
library = self.stage(self.presentation("idle", download_state="downloading"))
|
||||
self.assertEqual(library["stateLabel"], "Downloading")
|
||||
self.assertIn("Not currently searching", library["summary"])
|
||||
|
||||
def test_an_old_missing_download_does_not_mark_search_complete(self):
|
||||
search = self.stage(self.presentation("idle", download_state="missing"), "search")
|
||||
self.assertEqual(search["state"], "waiting")
|
||||
|
||||
|
||||
class SearchSnapshotIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_movie_eligibility_is_not_search_activity_and_tv_commands_are_checked(self):
|
||||
for media_type in [RequestType.movie, RequestType.tv]:
|
||||
for commands, expected in [([], "idle"), ([command("MoviesSearch", movieIds=[12]), command("EpisodeSearch", episodeIds=[109])], "searching")]:
|
||||
with self.subTest(media_type=media_type, search=expected), ExitStack() as stack:
|
||||
runtime = settings.model_copy(update={"requests_data_source": "prefer_cache"})
|
||||
item = {"id": 12, "title": "Example", "hasFile": False, "isAvailable": True, "monitored": True}
|
||||
collector = SimpleNamespace(
|
||||
get_movie_by_tmdb_id=AsyncMock(return_value=[item]),
|
||||
get_series_by_tvdb_id=AsyncMock(return_value=[item]),
|
||||
get_episodes=AsyncMock(return_value=[{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9, "monitored": True, "hasFile": False}]),
|
||||
get_queue=AsyncMock(return_value={"records": []}),
|
||||
get=AsyncMock(return_value=commands),
|
||||
)
|
||||
mocks = {
|
||||
"get_runtime_settings": runtime,
|
||||
"get_request_cache_payload": {"id": 12, "type": media_type.value, "status": 2,
|
||||
"media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
|
||||
"get_request_cache_by_id": None,
|
||||
"JellyseerrClient": SimpleNamespace(configured=lambda: False),
|
||||
"JellyfinClient": SimpleNamespace(configured=lambda: False),
|
||||
"QBittorrentClient": SimpleNamespace(configured=lambda: False),
|
||||
"SonarrClient": collector, "RadarrClient": collector,
|
||||
"ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
|
||||
"get_request_download_evidence": {}, "get_request_repairs": [], "_latest_repair_action": None, "save_snapshot": None,
|
||||
}
|
||||
for name, value in mocks.items():
|
||||
stack.enter_context(patch.object(snapshot_service, name, return_value=value))
|
||||
stack.enter_context(patch.object(snapshot_service, "_maybe_refresh_jellyfin", new=AsyncMock()))
|
||||
snapshot = await snapshot_service.build_snapshot("12")
|
||||
collector.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
|
||||
self.assertEqual(snapshot.state, NormalizedState.searching if expected == "searching" else NormalizedState.added_to_arr)
|
||||
library = next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == "library")
|
||||
self.assertEqual(library["searchStatus"], expected)
|
||||
@@ -0,0 +1,519 @@
|
||||
"""Managed installation regression tests; use only disposable local files."""
|
||||
|
||||
import base64
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import closing, redirect_stderr, redirect_stdout
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import stat
|
||||
import tempfile
|
||||
from threading import Barrier
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.app import container_bootstrap as bootstrap
|
||||
|
||||
|
||||
class ContainerBootstrapTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.data = self.root / "data"
|
||||
self.data.mkdir(mode=0o700)
|
||||
self.state_path = self.data / bootstrap.STATE_FILENAME
|
||||
self.database = self.data / "magent.db"
|
||||
self.environment = {
|
||||
"MAGENT_MANAGED_SECRETS": "true",
|
||||
"MAGENT_APPLICATION_URL": "https://magent.example.test",
|
||||
}
|
||||
|
||||
def prepare(self, **changes):
|
||||
return bootstrap.prepare_environment({**self.environment, **changes}, self.data)
|
||||
|
||||
def state(self):
|
||||
return json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
|
||||
def create_database(self, *, completed=0, admin=False):
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE installation_setup (id INTEGER PRIMARY KEY, completed INTEGER)")
|
||||
connection.execute("INSERT INTO installation_setup VALUES (1, ?)", (completed,))
|
||||
connection.execute("CREATE TABLE users (role TEXT)")
|
||||
if admin:
|
||||
connection.execute("INSERT INTO users VALUES ('ADMIN')")
|
||||
|
||||
def create_symlink(self, path, target, *, directory=False):
|
||||
try:
|
||||
path.symlink_to(target, target_is_directory=directory)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
self.skipTest(f"This platform cannot create test symlinks: {type(exc).__name__}")
|
||||
|
||||
def test_fresh_install_generates_independent_valid_random_secrets(self):
|
||||
before = dict(self.environment)
|
||||
prepared = self.prepare()
|
||||
state = self.state()
|
||||
self.assertEqual(self.environment, before)
|
||||
self.assertEqual(set(state), {"version", *bootstrap.SECRET_NAMES})
|
||||
self.assertEqual(state["version"], 1)
|
||||
for name in ("JWT_SECRET", "SETUP_TOKEN"):
|
||||
self.assertRegex(state[name], r"^[A-Za-z0-9_-]{64}$")
|
||||
self.assertNotEqual(state["JWT_SECRET"], state["SETUP_TOKEN"])
|
||||
self.assertEqual(len(base64.urlsafe_b64decode(state["SETTINGS_ENCRYPTION_KEY"])), 32)
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertEqual(prepared[name], state[name])
|
||||
self.assertEqual(prepared["SQLITE_PATH"], str(self.database.absolute()))
|
||||
self.assertFalse(self.database.exists())
|
||||
self.assertEqual(list(self.data.glob(".magent-secrets-*")), [])
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "POSIX filesystem ownership/permissions")
|
||||
def test_state_has_private_permissions_and_runtime_ownership(self):
|
||||
self.prepare()
|
||||
metadata = self.state_path.stat()
|
||||
self.assertEqual(stat.S_IMODE(metadata.st_mode), 0o600)
|
||||
self.assertEqual(metadata.st_uid, os.geteuid())
|
||||
|
||||
def test_separate_installations_get_different_secrets(self):
|
||||
first = self.prepare()
|
||||
other = self.root / "other"
|
||||
other.mkdir(mode=0o700)
|
||||
second = bootstrap.prepare_environment(self.environment, other)
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertNotEqual(first[name], second[name])
|
||||
|
||||
def test_restart_and_existing_database_reuse_exact_file_and_values(self):
|
||||
first = self.prepare()
|
||||
original = self.state_path.read_bytes()
|
||||
original_modified = self.state_path.stat().st_mtime_ns
|
||||
self.create_database(admin=True)
|
||||
with patch.object(bootstrap.secrets, "token_bytes", side_effect=AssertionError("Must not regenerate")), \
|
||||
patch.object(bootstrap.secrets, "token_urlsafe", side_effect=AssertionError("Must not regenerate")):
|
||||
second = self.prepare()
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(self.state_path.read_bytes(), original)
|
||||
self.assertEqual(self.state_path.stat().st_mtime_ns, original_modified)
|
||||
|
||||
def test_disabled_mode_is_an_unchanged_copy_without_filesystem_access(self):
|
||||
for value in (None, "false", "0", "no", "", " FALSE "):
|
||||
with self.subTest(mode=value):
|
||||
environment = {"JWT_SECRET": "legacy-key", "MAGENT_APPLICATION_URL": "invalid"}
|
||||
if value is not None:
|
||||
environment["MAGENT_MANAGED_SECRETS"] = value
|
||||
result = bootstrap.prepare_environment(environment, self.root / "does-not-exist")
|
||||
self.assertEqual(result, environment)
|
||||
self.assertIsNot(result, environment)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_invalid_managed_mode_fails_before_writing(self):
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare(MAGENT_MANAGED_SECRETS="perhaps")
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_auto_mode_generates_fresh_install_keys_without_explicit_jwt(self):
|
||||
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
|
||||
self.assertTrue(self.state_path.exists())
|
||||
self.assertEqual(prepared["MAGENT_MANAGED_SECRETS"], "true")
|
||||
self.assertEqual(prepared["MAGENT_RUNTIME_MANAGED"], "1")
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertEqual(prepared[name], self.state()[name])
|
||||
|
||||
def test_auto_mode_preserves_explicit_jwt_manual_install_without_filesystem_access(self):
|
||||
environment = {
|
||||
"MAGENT_MANAGED_SECRETS": "auto",
|
||||
"JWT_SECRET": "legacy-explicit-signing-key",
|
||||
"SQLITE_PATH": "/existing/custom-database.db",
|
||||
"API_DOCS_ENABLED": "true",
|
||||
"MAGENT_APPLICATION_URL": "https://legacy.example.test",
|
||||
"CORS_ALLOW_ORIGIN": "https://legacy.example.test",
|
||||
}
|
||||
prepared = bootstrap.prepare_environment(environment, self.root / "does-not-exist")
|
||||
self.assertEqual(prepared, environment)
|
||||
self.assertIsNot(prepared, environment)
|
||||
self.assertNotIn("SETTINGS_ENCRYPTION_KEY", prepared)
|
||||
self.assertNotIn("MAGENT_RUNTIME_MANAGED", prepared)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_auto_mode_whitespace_jwt_is_treated_as_unset(self):
|
||||
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto", "JWT_SECRET": " "}, self.data)
|
||||
self.assertEqual(prepared["JWT_SECRET"], self.state()["JWT_SECRET"])
|
||||
|
||||
def test_absent_application_url_uses_fixed_defaults_without_claiming_an_origin(self):
|
||||
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
|
||||
self.assertFalse(prepared.get("MAGENT_APPLICATION_URL"))
|
||||
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://localhost:3000")
|
||||
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "false")
|
||||
self.assertEqual(prepared["API_DOCS_ENABLED"], "false")
|
||||
self.assertEqual(prepared["SQLITE_PATH"], str(self.database.absolute()))
|
||||
|
||||
def test_empty_application_url_is_deferred_to_setup(self):
|
||||
prepared = self.prepare(MAGENT_APPLICATION_URL="")
|
||||
self.assertEqual(prepared["MAGENT_APPLICATION_URL"], "")
|
||||
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://localhost:3000")
|
||||
self.assertTrue(self.state_path.exists())
|
||||
|
||||
def test_managed_api_docs_cannot_be_enabled(self):
|
||||
for value in ("true", "1", "yes", "on", "invalid"):
|
||||
with self.subTest(value=value), self.assertRaisesRegex(bootstrap.BootstrapError, "API_DOCS_ENABLED"):
|
||||
self.prepare(API_DOCS_ENABLED=value)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_saved_public_url_controls_restart_without_key_regeneration(self):
|
||||
original = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
|
||||
state_bytes = self.state_path.read_bytes()
|
||||
self.create_database(admin=True)
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
|
||||
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'https://saved.example.test')")
|
||||
restarted = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
|
||||
self.assertEqual(restarted["MAGENT_APPLICATION_URL"], "https://saved.example.test")
|
||||
self.assertEqual(restarted["CORS_ALLOW_ORIGIN"], "https://saved.example.test")
|
||||
self.assertEqual(restarted["AUTH_COOKIE_SECURE"], "true")
|
||||
self.assertEqual(self.state_path.read_bytes(), state_bytes)
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertEqual(restarted[name], original[name])
|
||||
|
||||
def test_saved_public_url_wins_over_stale_deployment_url_on_restart(self):
|
||||
self.prepare()
|
||||
self.create_database(admin=True)
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
|
||||
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'http://magent.lan:3000')")
|
||||
restarted = self.prepare(CORS_ALLOW_ORIGIN="https://magent.example.test")
|
||||
self.assertEqual(restarted["MAGENT_APPLICATION_URL"], "http://magent.lan:3000")
|
||||
self.assertEqual(restarted["CORS_ALLOW_ORIGIN"], "http://magent.lan:3000")
|
||||
self.assertEqual(restarted["AUTH_COOKIE_SECURE"], "false")
|
||||
|
||||
def test_invalid_saved_url_fails_closed_without_changing_keys(self):
|
||||
self.prepare()
|
||||
original = self.state_path.read_bytes()
|
||||
self.create_database(admin=True)
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
|
||||
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'https://user:secret@evil.test')")
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertEqual(self.state_path.read_bytes(), original)
|
||||
|
||||
def test_existing_database_or_recovery_sidecar_never_generates_replacement_keys(self):
|
||||
for suffix in ("", "-wal", "-shm", "-journal"):
|
||||
with self.subTest(suffix=suffix):
|
||||
path = Path(str(self.database) + suffix)
|
||||
path.write_bytes(b"existing installation data")
|
||||
try:
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertEqual(path.read_bytes(), b"existing installation data")
|
||||
self.assertFalse(self.state_path.exists())
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
def test_lost_keys_after_initialization_are_not_recreated(self):
|
||||
self.prepare()
|
||||
self.create_database()
|
||||
self.state_path.unlink()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_fresh_manual_secrets_conflict_without_writing_state(self):
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
with self.subTest(name=name), self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare(**{name: "synthetic-manual-secret"})
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_matching_environment_values_are_accepted_but_conflicts_never_replace_file(self):
|
||||
first = self.prepare()
|
||||
original = self.state_path.read_bytes()
|
||||
keys = {name: first[name] for name in bootstrap.SECRET_NAMES}
|
||||
self.assertEqual(self.prepare(**keys), first)
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
with self.subTest(name=name), self.assertRaises(bootstrap.BootstrapError) as raised:
|
||||
self.prepare(**{name: "conflicting-private-value"})
|
||||
self.assertNotIn("conflicting-private-value", str(raised.exception))
|
||||
self.assertEqual(self.state_path.read_bytes(), original)
|
||||
|
||||
def test_custom_database_location_is_rejected_without_touching_it(self):
|
||||
custom = self.root / "other.db"
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare(SQLITE_PATH=str(custom))
|
||||
self.assertFalse(custom.exists())
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_missing_or_symlink_data_directory_is_rejected(self):
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.prepare_environment(self.environment, self.root / "missing")
|
||||
linked = self.root / "linked-data"
|
||||
self.create_symlink(linked, self.data, directory=True)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.prepare_environment(self.environment, linked)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "POSIX filesystem permissions")
|
||||
def test_shared_writable_data_directory_is_rejected(self):
|
||||
self.data.chmod(0o777)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_malformed_json_oversized_and_invalid_schema_never_get_replaced(self):
|
||||
self.prepare()
|
||||
valid = self.state()
|
||||
invalid_states = [
|
||||
b"not-json", b"\xff", b"x" * (bootstrap.MAX_STATE_BYTES + 1), b"[]", b"{}",
|
||||
json.dumps({**valid, "version": True}).encode(),
|
||||
json.dumps({**valid, "version": 2}).encode(),
|
||||
json.dumps({**valid, "unexpected": "value"}).encode(),
|
||||
json.dumps({**valid, "JWT_SECRET": None}).encode(),
|
||||
json.dumps({**valid, "JWT_SECRET": "a" * 64}).encode(),
|
||||
json.dumps({**valid, "JWT_SECRET": "short"}).encode(),
|
||||
json.dumps({**valid, "SETUP_TOKEN": valid["JWT_SECRET"]}).encode(),
|
||||
json.dumps({**valid, "SETTINGS_ENCRYPTION_KEY": "invalid-key"}).encode(),
|
||||
json.dumps({**valid, "SETTINGS_ENCRYPTION_KEY": base64.urlsafe_b64encode(b"short").decode()}).encode(),
|
||||
]
|
||||
for index, payload in enumerate(invalid_states):
|
||||
with self.subTest(case=index):
|
||||
self.state_path.write_bytes(payload)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertEqual(self.state_path.read_bytes(), payload)
|
||||
|
||||
def test_state_directory_is_not_replaced(self):
|
||||
self.state_path.mkdir()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertTrue(self.state_path.is_dir())
|
||||
|
||||
def test_state_symlink_is_not_followed_or_replaced(self):
|
||||
self.prepare()
|
||||
target = self.root / "original-secrets.json"
|
||||
self.state_path.rename(target)
|
||||
original = target.read_bytes()
|
||||
self.create_symlink(self.state_path, target)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertEqual(target.read_bytes(), original)
|
||||
self.assertTrue(self.state_path.is_symlink())
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "POSIX filesystem permissions")
|
||||
def test_publicly_readable_secrets_are_rejected_without_fixing_or_overwriting_them(self):
|
||||
self.prepare()
|
||||
original = self.state_path.read_bytes()
|
||||
self.state_path.chmod(0o644)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertEqual(stat.S_IMODE(self.state_path.stat().st_mode), 0o644)
|
||||
self.assertEqual(self.state_path.read_bytes(), original)
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "mkfifo"), "POSIX named pipes")
|
||||
def test_named_pipe_state_is_rejected_without_blocking(self):
|
||||
os.mkfifo(self.state_path, 0o600)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertTrue(stat.S_ISFIFO(self.state_path.stat().st_mode))
|
||||
|
||||
def test_https_sets_matching_cors_and_secure_cookies(self):
|
||||
prepared = self.prepare()
|
||||
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], self.environment["MAGENT_APPLICATION_URL"])
|
||||
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "true")
|
||||
|
||||
def test_explicit_http_lan_origin_disables_secure_cookie_flag_only(self):
|
||||
prepared = self.prepare(MAGENT_APPLICATION_URL="http://192.0.2.10:3000")
|
||||
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://192.0.2.10:3000")
|
||||
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "false")
|
||||
|
||||
def test_invalid_origin_fails_without_creating_keys(self):
|
||||
origins = (
|
||||
"not-a-url", "https://magent.example.test/", "https://magent.example.test/path",
|
||||
"//magent.example.test", "ftp://magent.example.test", "http:/magent.example.test",
|
||||
"https://user:password@magent.example.test", "https://@magent.example.test",
|
||||
"https://magent.example.test?", "https://magent.example.test#",
|
||||
"https://magent.example.test:0", "https://magent.example.test:65536",
|
||||
"https://*.example.test", "https://magent.\ttest", "https://magent.example.test\\path",
|
||||
" https://magent.example.test", "https://magent.example.test\x00",
|
||||
)
|
||||
for origin in origins:
|
||||
with self.subTest(origin=repr(origin)), self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare(MAGENT_APPLICATION_URL=origin)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_cors_mismatch_or_cookie_scheme_conflict_fails_without_keys(self):
|
||||
cases = (
|
||||
{"CORS_ALLOW_ORIGIN": "https://elsewhere.example.test"},
|
||||
{"AUTH_COOKIE_SECURE": "false"},
|
||||
{"AUTH_COOKIE_SECURE": "0"},
|
||||
{"AUTH_COOKIE_SECURE": "maybe"},
|
||||
{"MAGENT_APPLICATION_URL": "http://magent.lan:3000", "AUTH_COOKIE_SECURE": "true"},
|
||||
{"MAGENT_APPLICATION_URL": "http://magent.lan:3000", "AUTH_COOKIE_SECURE": "1"},
|
||||
)
|
||||
for changes in cases:
|
||||
with self.subTest(changes=changes), self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare(**changes)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_racing_initializers_publish_and_return_one_complete_state(self):
|
||||
barrier = Barrier(8)
|
||||
|
||||
def initialize(_):
|
||||
barrier.wait(timeout=10)
|
||||
return self.prepare()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
results = list(executor.map(initialize, range(8)))
|
||||
for result in results:
|
||||
self.assertEqual(result, results[0])
|
||||
state = self.state()
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertEqual(state[name], results[0][name])
|
||||
self.assertEqual(list(self.data.glob(".magent-secrets-*")), [])
|
||||
|
||||
def test_token_command_requires_managed_mode_and_does_not_create_state(self):
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token({}, self.data)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
with self.assertRaises((bootstrap.BootstrapError, FileNotFoundError)):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
self.assertFalse(self.database.exists())
|
||||
|
||||
def test_token_command_does_not_create_an_uninitialized_database(self):
|
||||
self.prepare()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertFalse(self.database.exists())
|
||||
|
||||
def test_token_command_returns_only_initial_token_using_readonly_closed_connection(self):
|
||||
prepared = self.prepare()
|
||||
self.create_database()
|
||||
before = {path.name: path.read_bytes() for path in self.data.iterdir()}
|
||||
connections = []
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
def connect(*args, **kwargs):
|
||||
self.assertTrue(kwargs.get("uri"))
|
||||
self.assertTrue(args[0].endswith("?mode=ro"))
|
||||
connection = real_connect(*args, **kwargs)
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
connection.execute("INSERT INTO users VALUES ('admin')")
|
||||
connections.append(connection)
|
||||
return connection
|
||||
|
||||
with patch.object(bootstrap.sqlite3, "connect", side_effect=connect):
|
||||
token = bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertEqual(token, prepared["SETUP_TOKEN"])
|
||||
self.assertEqual({path.name: path.read_bytes() for path in self.data.iterdir()}, before)
|
||||
for connection in connections:
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
connection.execute("SELECT 1")
|
||||
|
||||
def test_token_command_refuses_once_any_admin_exists_even_before_setup_completion(self):
|
||||
self.prepare()
|
||||
self.create_database(admin=True)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
|
||||
def test_token_command_refuses_completed_setup_even_without_admin(self):
|
||||
self.prepare()
|
||||
self.create_database(completed=1)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
|
||||
def test_token_command_refuses_unknown_or_invalid_database_state(self):
|
||||
self.prepare()
|
||||
for payload in (b"", b"not a SQLite database"):
|
||||
with self.subTest(payload=payload):
|
||||
self.database.write_bytes(payload)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertEqual(self.database.read_bytes(), payload)
|
||||
self.database.unlink()
|
||||
self.create_database()
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
with connection:
|
||||
connection.execute("DELETE FROM installation_setup")
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
|
||||
def test_existing_database_symlink_is_rejected_even_with_valid_state(self):
|
||||
self.prepare()
|
||||
self.create_database()
|
||||
target = self.root / "other.db"
|
||||
self.database.rename(target)
|
||||
original = target.read_bytes()
|
||||
self.create_symlink(self.database, target)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertEqual(target.read_bytes(), original)
|
||||
|
||||
def test_existing_database_directory_is_rejected_even_with_valid_state(self):
|
||||
self.prepare()
|
||||
self.database.mkdir()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertTrue(self.database.is_dir())
|
||||
|
||||
def test_startup_passes_keys_to_runtime_without_printing_them(self):
|
||||
prepared = self.prepare()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with patch.dict(os.environ, self.environment, clear=True), \
|
||||
patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord", "-c", "config"]), \
|
||||
patch.object(bootstrap, "prepare_environment", return_value=prepared), \
|
||||
patch.object(bootstrap.os, "execvpe") as execute, \
|
||||
redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
self.assertEqual(bootstrap.main(), 0)
|
||||
execute.assert_called_once_with("supervisord", ["supervisord", "-c", "config"], prepared)
|
||||
self.assertIn("setup-token", stdout.getvalue())
|
||||
self.assertEqual(stderr.getvalue(), "")
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertNotIn(prepared[name], stdout.getvalue() + stderr.getvalue())
|
||||
|
||||
def test_disabled_startup_does_not_print_managed_install_instructions(self):
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
environment = {"JWT_SECRET": "manual-test-value"}
|
||||
with patch.dict(os.environ, environment, clear=True), \
|
||||
patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord"]), \
|
||||
patch.object(bootstrap.os, "execvpe") as execute, \
|
||||
redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
self.assertEqual(bootstrap.main(), 0)
|
||||
execute.assert_called_once_with("supervisord", ["supervisord"], environment)
|
||||
self.assertEqual(stdout.getvalue() + stderr.getvalue(), "")
|
||||
|
||||
def test_cli_explicit_token_command_prints_only_token_not_other_keys(self):
|
||||
prepared = self.prepare()
|
||||
self.create_database()
|
||||
retrieve = bootstrap.setup_token
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with patch.dict(os.environ, self.environment, clear=True), \
|
||||
patch.object(bootstrap.sys, "argv", ["bootstrap", "setup-token"]), \
|
||||
patch.object(bootstrap, "setup_token", side_effect=lambda env: retrieve(env, self.data)), \
|
||||
patch.object(bootstrap.os, "execvpe") as execute, \
|
||||
redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
self.assertEqual(bootstrap.main(), 0)
|
||||
execute.assert_not_called()
|
||||
self.assertEqual(stdout.getvalue(), prepared["SETUP_TOKEN"] + "\n")
|
||||
self.assertEqual(stderr.getvalue(), "")
|
||||
self.assertNotIn(prepared["JWT_SECRET"], stdout.getvalue())
|
||||
self.assertNotIn(prepared["SETTINGS_ENCRYPTION_KEY"], stdout.getvalue())
|
||||
|
||||
def test_cli_unexpected_io_failure_never_logs_sensitive_exception_details(self):
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord"]), \
|
||||
patch.object(bootstrap, "prepare_environment", side_effect=OSError("private-secret-material")), \
|
||||
redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
self.assertEqual(bootstrap.main(), 1)
|
||||
self.assertEqual(stdout.getvalue(), "")
|
||||
self.assertNotIn("private-secret-material", stderr.getvalue())
|
||||
self.assertIn("Check volume permissions", stderr.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Unit checks for the release smoke harness; no Docker or network required."""
|
||||
|
||||
from email.message import Message
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
HELPER_PATH = Path(__file__).resolve().parents[2] / "scripts" / "container_smoke.py"
|
||||
SPEC = importlib.util.spec_from_file_location("magent_container_smoke", HELPER_PATH)
|
||||
smoke = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(smoke)
|
||||
|
||||
|
||||
def response_headers(**changes):
|
||||
headers = Message()
|
||||
for key, value in {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'nonce-test-nonce' 'strict-dynamic'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
**changes,
|
||||
}.items():
|
||||
headers[key] = value
|
||||
return headers
|
||||
|
||||
|
||||
class ContainerPackagingHarnessTests(unittest.TestCase):
|
||||
def test_backup_multipart_preserves_binary_content_and_required_fields(self):
|
||||
content = b"MAGENT-BACKUP\x00\x01\xff\r\n\x00encrypted"
|
||||
body, content_type = smoke.backup_restore_upload(content, "synthetic backup passphrase")
|
||||
parsed = BytesParser(policy=default).parsebytes(
|
||||
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + body,
|
||||
)
|
||||
fields = {part.get_param("name", header="content-disposition"): part
|
||||
for part in parsed.iter_parts()}
|
||||
self.assertEqual(set(fields), {"passphrase", "confirmation", "file"})
|
||||
self.assertEqual(fields["passphrase"].get_payload(decode=True), b"synthetic backup passphrase")
|
||||
self.assertEqual(fields["confirmation"].get_payload(decode=True), b"RESTORE")
|
||||
self.assertEqual(fields["file"].get_payload(decode=True), content)
|
||||
self.assertEqual(fields["file"].get_filename(), "smoke.magent-backup")
|
||||
|
||||
def test_http_rejects_conflicting_body_encodings_without_network(self):
|
||||
with patch.object(smoke.request, "urlopen") as urlopen:
|
||||
with self.assertRaisesRegex(AssertionError, "only one encoding"):
|
||||
smoke.http("/test", payload={}, raw=b"binary")
|
||||
urlopen.assert_not_called()
|
||||
|
||||
def page(self, *, nonce="test-nonce", source="/_next/static/app.js", extra=""):
|
||||
return (
|
||||
f'<script nonce="{nonce}" src="{source}"></script>'
|
||||
f'<script nonce="{nonce}">self.__next_f.push([])</script>'
|
||||
'<link rel="stylesheet" href="/_next/static/app.css">'
|
||||
f"{extra}"
|
||||
).encode()
|
||||
|
||||
def test_static_assets_and_every_bootstrap_script_are_validated(self):
|
||||
seen = []
|
||||
|
||||
def fake_http(path):
|
||||
seen.append(path)
|
||||
if path == "/login":
|
||||
return self.page(), response_headers()
|
||||
return b"static content", response_headers(**{"Content-Type": "application/javascript"})
|
||||
|
||||
with patch.object(smoke, "http", side_effect=fake_http):
|
||||
assets = set()
|
||||
self.assertEqual(smoke.check_page("/login", assets), "test-nonce")
|
||||
self.assertEqual(assets, {"/_next/static/app.js", "/_next/static/app.css"})
|
||||
self.assertEqual(seen, ["/login", "/_next/static/app.css", "/_next/static/app.js"])
|
||||
smoke.check_page("/login", assets)
|
||||
self.assertEqual(seen[-1], "/login")
|
||||
self.assertEqual(len(seen), 4)
|
||||
|
||||
def test_nonce_mismatch_fails_before_fetching_assets(self):
|
||||
with patch.object(smoke, "http", return_value=(self.page(nonce="wrong"), response_headers())):
|
||||
with self.assertRaisesRegex(AssertionError, "script blocked by its CSP nonce"):
|
||||
smoke.check_page("/login", set())
|
||||
|
||||
def test_missing_nonce_policy_is_rejected(self):
|
||||
headers = response_headers(**{"Content-Security-Policy": "script-src 'self'"})
|
||||
with patch.object(smoke, "http", return_value=(self.page(), headers)):
|
||||
with self.assertRaisesRegex(AssertionError, "missing script nonce policy"):
|
||||
smoke.check_page("/login", set())
|
||||
|
||||
def test_development_eval_policy_is_rejected(self):
|
||||
headers = response_headers(**{
|
||||
"Content-Security-Policy": "script-src 'nonce-test-nonce' 'strict-dynamic' 'unsafe-eval'",
|
||||
})
|
||||
with patch.object(smoke, "http", return_value=(self.page(), headers)):
|
||||
with self.assertRaisesRegex(AssertionError, "development eval"):
|
||||
smoke.check_page("/login", set())
|
||||
|
||||
def test_html_fallback_for_static_asset_is_rejected(self):
|
||||
with patch.object(smoke, "http", return_value=(self.page(), response_headers())):
|
||||
with self.assertRaisesRegex(AssertionError, "Asset returned HTML"):
|
||||
smoke.check_page("/login", set())
|
||||
|
||||
def test_missing_executable_script_nonce_is_rejected(self):
|
||||
page = self.page(extra='<script src="/_next/static/missing-nonce.js"></script>')
|
||||
with patch.object(smoke, "http", return_value=(page, response_headers())):
|
||||
with self.assertRaisesRegex(AssertionError, "script blocked by its CSP nonce"):
|
||||
smoke.check_page("/login", set())
|
||||
|
||||
def test_inert_json_scripts_do_not_require_executable_nonce(self):
|
||||
page = self.page(extra='<script type="application/ld+json">{"name":"Magent"}</script>')
|
||||
with patch.object(smoke, "http", return_value=(page, response_headers())):
|
||||
cache = {"/_next/static/app.js", "/_next/static/app.css"}
|
||||
self.assertEqual(smoke.check_page("/login", cache), "test-nonce")
|
||||
|
||||
def test_external_scripts_are_not_followed_by_smoke_harness(self):
|
||||
page = self.page(extra='<script nonce="test-nonce" src="https://external.invalid/app.js"></script>')
|
||||
with patch.object(smoke, "http", return_value=(page, response_headers())):
|
||||
with self.assertRaisesRegex(AssertionError, "Unexpected external executable asset"):
|
||||
smoke.check_page("/login", {"/_next/static/app.js", "/_next/static/app.css"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,182 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.app import db
|
||||
from backend.app.auth import get_current_user
|
||||
from backend.app.feature_access import permissions, update_permissions
|
||||
from backend.app.routers import identities
|
||||
from backend.app.services import duplicate_accounts as duplicates, identity_review as review
|
||||
from backend.app.services.jellyfin_identity import link_user
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
JF, SERVER = 'a' * 32, 'b' * 32
|
||||
|
||||
|
||||
class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
|
||||
self.keep = db.get_user_by_username('Viewer')['id']
|
||||
with db._connect() as conn:
|
||||
self.extra = conn.execute("""INSERT INTO users(username,password_hash,role,auth_provider,
|
||||
jellyseerr_user_id,created_at) VALUES('viewer ','old-hash','user','jellyfin',42,'2026-01-01')""").lastrowid
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test',
|
||||
jellyseerr_base_url='http://seerr', jellyseerr_api_key='test', jellystat_base_url='http://stats', jellystat_api_key='test')
|
||||
link_user('Viewer', JF, 'http://jf')
|
||||
self.jf = {'state': 'available', 'server_id': SERVER, 'users': [{'id': JF, 'name': 'Viewer'}]}
|
||||
self.seerr = {'state': 'available', 'users': [{'id': 42, 'name': 'Viewer', 'jellyfin_id': JF}]}
|
||||
for name, value in [('get_runtime_settings', self.runtime), ('jellyfin_directory', self.jf), ('seerr_directory', self.seerr)]:
|
||||
mocked = patch.object(review, name, return_value=value)
|
||||
mocked.start(); self.addCleanup(mocked.stop)
|
||||
mocked = patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock,
|
||||
return_value={JF: {'state': 'matched', 'id': JF}})
|
||||
mocked.start(); self.addCleanup(mocked.stop)
|
||||
|
||||
async def test_consolidation_preserves_history_and_restrictive_access(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute('UPDATE users SET auto_search_enabled=0,expires_at=? WHERE id=?', ('2026-01-01T00:00:00+00:00', self.extra))
|
||||
conn.execute('INSERT INTO user_feature_permissions VALUES(?,?,?)', (self.extra, 'issues', 0))
|
||||
db.upsert_user_activity('Viewer', '127.0.0.1', 'test')
|
||||
db.upsert_user_activity('viewer ', '127.0.0.1', 'test')
|
||||
item = db.create_portal_item(kind='issue', title='Issue', description='History', created_by_username='viewer ', created_by_id=42)
|
||||
before = review.read_snapshot()
|
||||
preview = await duplicates.repair_duplicates(self.extra)
|
||||
self.assertEqual(review.read_snapshot(), before, 'Preview must not mutate accounts')
|
||||
self.assertTrue(preview['can_confirm'], preview['issues'])
|
||||
self.assertEqual(preview['keep_id'], self.keep)
|
||||
self.assertNotIn('old-hash', json.dumps(preview))
|
||||
result = await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
|
||||
self.assertEqual(result['consolidated'], 1)
|
||||
self.assertIsNone(db.get_user_by_id(self.extra))
|
||||
user = db.get_user_by_username('Viewer')
|
||||
self.assertEqual(user['id'], self.keep)
|
||||
self.assertFalse(user['auto_search_enabled'])
|
||||
self.assertFalse(permissions(user)['issues'])
|
||||
self.assertTrue(user['is_expired'])
|
||||
self.assertEqual(db.get_portal_item(item['id'])['created_by_username'], 'Viewer')
|
||||
self.assertEqual(db.get_portal_item(item['id'])['created_by_id'], 42, 'IDs here belong to Seerr')
|
||||
with db._connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT SUM(hit_count) FROM user_activity').fetchone()[0], 2)
|
||||
archive = json.loads(conn.execute('SELECT archive_json FROM user_duplicate_repairs').fetchone()[0])
|
||||
self.assertEqual(len(archive['users']), 2)
|
||||
self.assertEqual(conn.execute('SELECT local_user_id FROM jellyfin_user_links').fetchone()[0], self.keep)
|
||||
report, _, _ = await review.review_identities()
|
||||
self.assertEqual(next(row for row in report['rows'] if row['user']['id'] == self.keep)['state'], 'confirmed')
|
||||
self.assertFalse(db.create_user_if_missing('VIEWER ', 'unused', auth_provider='jellyfin'))
|
||||
|
||||
async def test_choose_other_row_retains_its_settings_and_moves_link(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute('UPDATE users SET email=? WHERE id=?', ('chosen@example.test', self.extra))
|
||||
preview = await duplicates.repair_duplicates(self.keep, self.extra)
|
||||
self.assertEqual(preview['proposed']['email'], 'chosen@example.test')
|
||||
await duplicates.repair_duplicates(self.keep, self.extra, preview['revision'], {'username': 'admin'})
|
||||
self.assertEqual(db.get_user_by_username('Viewer')['id'], self.extra)
|
||||
self.assertEqual(db.get_user_by_id(self.extra)['username'], 'Viewer')
|
||||
|
||||
async def test_changed_permission_or_identity_rejects_stale_preview(self):
|
||||
preview, report, local, runtime, state = await duplicates.prepare(self.keep)
|
||||
update_permissions({'stats': False}, 'Viewer')
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
|
||||
self.assertEqual(caught.exception.status_code, 409)
|
||||
self.assertIsNotNone(db.get_user_by_id(self.extra))
|
||||
self.seerr['users'][0]['jellyfin_id'] = 'c' * 32
|
||||
with self.assertRaises(HTTPException):
|
||||
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
|
||||
|
||||
async def test_conflicting_identities_admins_and_other_owners_are_blocked(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute("UPDATE users SET role='admin' WHERE id=?", (self.extra,))
|
||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||
with db._connect() as conn:
|
||||
conn.execute("UPDATE users SET role='user',jellyseerr_user_id=99 WHERE id=?", (self.extra,))
|
||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||
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):
|
||||
preview, report, local, runtime, state = await duplicates.prepare(self.keep)
|
||||
with db._connect() as conn:
|
||||
conn.execute("CREATE TRIGGER prevent_test_delete BEFORE DELETE ON users BEGIN SELECT RAISE(ABORT,'fixture failure'); END")
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
|
||||
self.assertIsNotNone(db.get_user_by_id(self.extra))
|
||||
with db._connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_duplicate_repairs').fetchone()[0], 0)
|
||||
|
||||
async def test_creation_rejects_case_and_whitespace_variants(self):
|
||||
for name in ('viewer', 'VIEWER', ' Viewer '):
|
||||
self.assertFalse(db.create_user_if_missing(name, 'unused'))
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.create_user(name, 'unused')
|
||||
|
||||
async def test_unresolved_whitespace_accounts_keep_distinct_lookup(self):
|
||||
self.assertEqual(db.get_user_by_username('Viewer')['id'], self.keep)
|
||||
self.assertEqual(db.get_user_by_username('viewer ')['id'], self.extra)
|
||||
self.assertIsNone(db.get_user_by_username(' Viewer '), 'Do not guess between unresolved identities')
|
||||
|
||||
async def test_concurrent_imports_create_only_one_normalized_account(self):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda name: db.create_user_if_missing(name, 'Password-123456!'), ['New viewer', 'NEW VIEWER ']))
|
||||
self.assertEqual(sorted(results), [False, True])
|
||||
|
||||
def seed_delivery(self, state='queued'):
|
||||
with db._connect() as conn:
|
||||
for prefix in ('email_recap', 'newsletter'):
|
||||
for identity in (self.keep, self.extra):
|
||||
conn.execute(f'''INSERT INTO {prefix}_subscriptions(user_id,state,email,identity_source,identity_id,
|
||||
version,requested_at,unsubscribe_token) VALUES(?,?,?,?,?,?,?,?)''',
|
||||
(identity, 'enabled', 'viewer@example.test', review.source_key('http://jf'), JF, str(identity), 1, prefix + str(identity)))
|
||||
period = {'month': '2026-08'} if prefix == 'email_recap' else {'edition_id': 'edition', 'edition_revision': 1}
|
||||
values = {'id': prefix, 'dedupe_key': prefix, 'user_id': self.extra, **period, 'kind': 'test',
|
||||
'email': 'viewer@example.test', 'subscription_version': str(self.extra), 'public_url': 'https://example.test',
|
||||
'state': state, 'created_at': 1, 'updated_at': 1, 'next_attempt_at': 1}
|
||||
conn.execute(f"INSERT INTO {prefix}_deliveries({','.join(values)}) VALUES({','.join('?' for _ in values)})", tuple(values.values()))
|
||||
|
||||
async def test_email_history_retained_pending_cancelled_and_consent_not_inherited(self):
|
||||
self.seed_delivery()
|
||||
preview = await duplicates.repair_duplicates(self.extra)
|
||||
await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
|
||||
with db._connect() as conn:
|
||||
for prefix in ('email_recap', 'newsletter'):
|
||||
delivery = conn.execute(f'SELECT user_id,state FROM {prefix}_deliveries').fetchone()
|
||||
self.assertEqual(delivery, (self.keep, 'cancelled'))
|
||||
subs = conn.execute(f'SELECT user_id,state FROM {prefix}_subscriptions').fetchall()
|
||||
self.assertEqual(subs, [(self.keep, 'enabled')])
|
||||
|
||||
async def test_sending_email_blocks_repair_without_removing_accounts(self):
|
||||
self.seed_delivery('sending')
|
||||
preview = await duplicates.repair_duplicates(self.extra)
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
|
||||
self.assertEqual(caught.exception.status_code, 409)
|
||||
self.assertIsNotNone(db.get_user_by_id(self.extra))
|
||||
|
||||
async def test_duplicate_endpoints_are_admin_only(self):
|
||||
app = FastAPI(); app.include_router(identities.router)
|
||||
app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
|
||||
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))
|
||||
@@ -0,0 +1,575 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import smtplib
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.auth import get_current_user
|
||||
from backend.app.clients.jellystat import HistoryLimitError, JellystatError
|
||||
from backend.app.routers import recaps as router
|
||||
from backend.app.services import email_recaps as recaps, recap_email as mail, recap_store as store
|
||||
from backend.app.services.jellyfin_identity import link_user, source_key
|
||||
from backend.app.services.monthly_reports import change, month_periods, shift_month
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
def fixture_report():
|
||||
periods = month_periods(None, datetime.now(timezone.utc))
|
||||
summary = dict(minutes=1500, movies=8, episodes=24, plays=35, active_days=20, longest_streak=6)
|
||||
changes = {key: change(value, round(value / 2)) for key, value in summary.items()}
|
||||
changes['requests'] = change(3, 2)
|
||||
return {**periods, 'state': 'ready', 'summary': summary, 'changes': changes, 'requests': {'total': 3},
|
||||
'top_titles': [{'title': 'Severance', 'type': 'series', 'minutes': 460, 'plays': 10},
|
||||
{'title': 'Arrival', 'type': 'movie', 'minutes': 116, 'plays': 1}],
|
||||
'recent': [{'artwork_url': '/insights/artwork/SECRET?token=PRIVATE-TOKEN'}]}
|
||||
|
||||
|
||||
def runtime():
|
||||
return SimpleNamespace(jellyfin_base_url='http://jellyfin', jellystat_base_url='http://jellystat',
|
||||
jellystat_api_key='PRIVATE-STATS-KEY', magent_notify_enabled=True, magent_notify_email_enabled=True,
|
||||
magent_notify_email_smtp_host='127.0.0.1', magent_notify_email_smtp_port=1,
|
||||
magent_notify_email_smtp_username='', magent_notify_email_smtp_password='',
|
||||
magent_notify_email_from_address='magent@example.test', magent_notify_email_from_name='Magent',
|
||||
magent_notify_email_use_tls=False, magent_notify_email_use_ssl=False)
|
||||
|
||||
|
||||
class RecapFixture(TempDatabaseMixin):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
db.create_user('viewer', 'Example-Password123!', role='admin', email='viewer@example.test')
|
||||
link_user('viewer', 'jf-viewer', 'http://jellyfin')
|
||||
self.user = db.get_user_by_username('viewer')
|
||||
self.runtime = runtime()
|
||||
for target, name, value in [(recaps, 'get_runtime_settings', self.runtime), (mail, 'get_runtime_settings', self.runtime),
|
||||
(recaps, 'smtp_email_config_ready', (True, 'ok'))]:
|
||||
mocked = patch.object(target, name, return_value=value)
|
||||
mocked.start(); self.addCleanup(mocked.stop)
|
||||
env = patch.dict('os.environ', {'BACKGROUND_TASKS_ENABLED': 'true'})
|
||||
env.start(); self.addCleanup(env.stop)
|
||||
self.config = dict(enabled=False, day=2, hour=9, public_url='https://beta.example.test')
|
||||
store.save_settings(self.config, datetime.now(timezone.utc))
|
||||
self.report = fixture_report()
|
||||
|
||||
def subscribe(self, timestamp=None):
|
||||
now = time.time() if timestamp is None else timestamp
|
||||
token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', now)
|
||||
sub = store.subscription(self.user['id'])
|
||||
self.assertTrue(store.confirm(sub, now + 1))
|
||||
return store.subscription(self.user['id']), token
|
||||
|
||||
def queue(self, sub=None, request_id='request-1'):
|
||||
if sub is None:
|
||||
sub, _ = self.subscribe()
|
||||
return store.enqueue_test(sub, self.report['month'], request_id, self.config['public_url'], time.time())
|
||||
|
||||
def delivery(self, delivery_id):
|
||||
return store.read_one('SELECT * FROM email_recap_deliveries WHERE id=?', (delivery_id,))
|
||||
|
||||
|
||||
class RecapConsentTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_opt_in_only_emails_confirmation_and_check_link_does_not_confirm(self):
|
||||
with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
|
||||
result = await recaps.subscribe(self.user)
|
||||
self.assertEqual(result['state'], 'pending')
|
||||
report.assert_not_called()
|
||||
recipient, rendered, _ = sender.call_args.args
|
||||
self.assertEqual(recipient, 'viewer@example.test')
|
||||
self.assertNotIn('Severance', rendered['body_html'])
|
||||
url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
|
||||
token = parse_qs(urlsplit(url).fragment)['token'][0]
|
||||
self.assertNotIn(token, store.subscription(self.user['id'])['confirmation_hash'])
|
||||
self.assertEqual(recaps.token_action(token, 'confirm')['state'], 'ready')
|
||||
self.assertEqual(store.subscription(self.user['id'])['state'], 'pending')
|
||||
self.assertEqual(recaps.token_action(token, 'confirm', apply=True)['state'], 'enabled')
|
||||
with self.assertRaises(recaps.RecapError):
|
||||
recaps.token_action(token, 'confirm', apply=True)
|
||||
with self.assertRaises(recaps.RecapError):
|
||||
recaps.token_action(token, 'unsubscribe', apply=True)
|
||||
|
||||
async def test_confirmation_failure_is_pending_and_resend_is_rate_limited(self):
|
||||
with patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'unknown')):
|
||||
with self.assertRaises(recaps.RecapError) as exc:
|
||||
await recaps.subscribe(self.user)
|
||||
self.assertEqual(exc.exception.status, 502)
|
||||
self.assertEqual(recaps.preferences(self.user)['state'], 'pending')
|
||||
with patch.object(mail, 'send_email') as sender:
|
||||
with self.assertRaises(recaps.RecapError) as exc:
|
||||
await recaps.subscribe(self.user)
|
||||
self.assertEqual(exc.exception.status, 429)
|
||||
sender.assert_not_called()
|
||||
|
||||
def test_unsubscribe_is_public_idempotent_and_cancels_queued_email(self):
|
||||
sub, _ = self.subscribe()
|
||||
delivery_id = self.queue(sub)
|
||||
token = sub['unsubscribe_token']
|
||||
self.assertEqual(recaps.token_action(token, 'unsubscribe')['state'], 'ready')
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'queued')
|
||||
recaps.token_action(token, 'unsubscribe', apply=True)
|
||||
self.assertEqual(recaps.token_action(token, 'unsubscribe', apply=True)['state'], 'off')
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||
|
||||
def test_expired_confirmation_does_not_subscribe(self):
|
||||
token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time() - 90000)
|
||||
self.assertEqual(recaps.preferences(self.user)['state'], 'expired')
|
||||
with self.assertRaises(recaps.RecapError):
|
||||
recaps.token_action(token, 'confirm', apply=True)
|
||||
|
||||
def test_email_change_back_does_not_restore_consent(self):
|
||||
self.subscribe()
|
||||
db.set_user_email('viewer', 'changed@example.test')
|
||||
db.set_user_email('viewer', 'viewer@example.test')
|
||||
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
|
||||
|
||||
def test_changed_link_or_source_requires_new_consent(self):
|
||||
self.subscribe()
|
||||
with store.transaction() as conn:
|
||||
conn.execute("UPDATE jellyfin_user_links SET jellyfin_user_id='new-identity' WHERE local_user_id=?", (self.user['id'],))
|
||||
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
|
||||
with store.transaction() as conn:
|
||||
conn.execute("UPDATE email_recap_subscriptions SET state='enabled'")
|
||||
self.runtime.jellyfin_base_url = 'http://other-jellyfin'
|
||||
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
|
||||
|
||||
def test_missing_email_or_stored_identity_cannot_subscribe(self):
|
||||
db.set_user_email('viewer', None)
|
||||
self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
|
||||
db.set_user_email('viewer', 'viewer@example.test')
|
||||
with store.transaction() as conn:
|
||||
conn.execute('DELETE FROM jellyfin_user_links')
|
||||
self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
|
||||
|
||||
def test_confirmation_rechecks_email_atomically(self):
|
||||
store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time())
|
||||
old = store.subscription(self.user['id'])
|
||||
db.set_user_email('viewer', 'different@example.test')
|
||||
self.assertFalse(store.confirm(old, time.time()))
|
||||
|
||||
|
||||
class RecapScheduleTests(RecapFixture, unittest.TestCase):
|
||||
def test_defaults_are_paused_and_no_users_are_opted_in(self):
|
||||
self.assertFalse(store.settings()['enabled'])
|
||||
self.assertEqual(store.history()['subscribers'], 0)
|
||||
self.assertEqual(store.enqueue_due(datetime.now(timezone.utc)), 0)
|
||||
|
||||
def test_utc_next_send_month_end_leap_year_and_new_year(self):
|
||||
for now, expected in [
|
||||
(datetime(2026, 12, 31, tzinfo=timezone.utc), '2027-01-02T09:00:00+00:00'),
|
||||
(datetime(2024, 2, 29, tzinfo=timezone.utc), '2024-03-02T09:00:00+00:00'),
|
||||
(datetime(2026, 9, 2, 8, tzinfo=timezone.utc), '2026-09-02T09:00:00+00:00'),
|
||||
(datetime(2026, 9, 2, 9, tzinfo=timezone.utc), '2026-10-02T09:00:00+00:00')]:
|
||||
self.assertEqual(store.next_due(now, 2, 9).isoformat(), expected)
|
||||
|
||||
def test_schedule_catches_up_once_and_excludes_late_subscribers(self):
|
||||
before = datetime(2026, 8, 30, tzinfo=timezone.utc)
|
||||
self.subscribe(before.timestamp())
|
||||
config = store.save_settings({**self.config, 'enabled': True}, before)
|
||||
self.assertEqual(config['next_send_at'], datetime(2026, 9, 2, 9, tzinfo=timezone.utc).timestamp())
|
||||
db.create_user('late', 'Example-Password123!', email='late@example.test')
|
||||
late = db.get_user_by_username('late')
|
||||
store.request_confirmation(late, 'source', 'late-id', datetime(2026, 9, 2, 10, tzinfo=timezone.utc).timestamp())
|
||||
store.confirm(store.subscription(late['id']), datetime(2026, 9, 2, 11, tzinfo=timezone.utc).timestamp())
|
||||
now = datetime(2026, 9, 5, tzinfo=timezone.utc)
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
counts = list(pool.map(store.enqueue_due, [now] * 4))
|
||||
self.assertEqual(sum(counts), 1)
|
||||
rows = store.history()['deliveries']
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]['month'], '2026-08')
|
||||
self.assertEqual(rows[0]['email'], 'viewer@example.test')
|
||||
# Revisit the same due date after a restart: the durable unique key still wins.
|
||||
with store.transaction() as conn:
|
||||
conn.execute('UPDATE email_recap_settings SET next_send_at=?', (config['next_send_at'],))
|
||||
self.assertEqual(store.enqueue_due(now), 0)
|
||||
|
||||
def test_long_downtime_does_not_backfill_multiple_months(self):
|
||||
before = datetime(2026, 5, 1, tzinfo=timezone.utc)
|
||||
self.subscribe(before.timestamp())
|
||||
store.save_settings({**self.config, 'enabled': True}, before)
|
||||
self.assertEqual(store.enqueue_due(datetime(2026, 9, 9, tzinfo=timezone.utc)), 1)
|
||||
self.assertEqual(store.history()['deliveries'][0]['month'], '2026-08')
|
||||
|
||||
def test_enable_after_due_date_waits_and_pause_cancels_pending_monthlies(self):
|
||||
now = datetime(2026, 9, 9, tzinfo=timezone.utc)
|
||||
self.subscribe(now.timestamp())
|
||||
result = store.save_settings({**self.config, 'enabled': True}, now)
|
||||
self.assertEqual(result['next_send_at'], datetime(2026, 10, 2, 9, tzinfo=timezone.utc).timestamp())
|
||||
self.assertEqual(store.enqueue_due(now), 0)
|
||||
store.enqueue_due(datetime(2026, 10, 3, tzinfo=timezone.utc))
|
||||
store.save_settings(self.config, now)
|
||||
self.assertEqual(store.history()['deliveries'][0]['state'], 'cancelled')
|
||||
self.assertIsNone(store.settings()['next_send_at'])
|
||||
|
||||
|
||||
class RecapDeliveryTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
|
||||
async def run_claim(self):
|
||||
delivery = store.claim_delivery(time.time())
|
||||
self.assertIsNotNone(delivery)
|
||||
await recaps.process_delivery(delivery)
|
||||
|
||||
async def test_private_report_is_delivered_once_using_confirmed_account(self):
|
||||
delivery_id = self.queue()
|
||||
sent = []
|
||||
def capture(recipient, rendered, message_id, before_data):
|
||||
before_data()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'sending')
|
||||
sent.append((recipient, rendered, message_id))
|
||||
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email', side_effect=capture):
|
||||
await recaps.run_once()
|
||||
await recaps.run_once()
|
||||
self.assertEqual(len(sent), 1)
|
||||
self.assertEqual(sent[0][0], 'viewer@example.test')
|
||||
self.assertIn(f'?month={self.report["month"]}', sent[0][1]['body_html'])
|
||||
self.assertNotIn('PRIVATE-TOKEN', json.dumps(sent))
|
||||
self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'sent')
|
||||
self.assertNotIn('unsubscribe_token', json.dumps(store.history()))
|
||||
|
||||
def test_concurrent_claim_and_test_deduplication(self):
|
||||
sub, _ = self.subscribe()
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
ids = list(pool.map(lambda _: self.queue(sub), range(4)))
|
||||
rows = list(pool.map(lambda _: store.claim_delivery(time.time()), range(4)))
|
||||
self.assertEqual(len(set(ids)), 1)
|
||||
self.assertEqual(sum(row is not None for row in rows), 1)
|
||||
with self.assertRaises(ValueError):
|
||||
self.queue(sub, 'another-click')
|
||||
|
||||
async def test_unsubscribe_or_email_change_during_report_prevents_sending(self):
|
||||
delivery_id = self.queue()
|
||||
async def report(*args):
|
||||
db.set_user_email('viewer', 'other@example.test')
|
||||
return self.report
|
||||
def transport(recipient, rendered, message_id, before_data):
|
||||
before_data()
|
||||
self.fail('Private data must not reach SMTP DATA after an address change')
|
||||
with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
|
||||
await self.run_claim()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||
|
||||
async def test_stats_permission_revoked_during_report_cancels_email(self):
|
||||
from backend.app.feature_access import update_permissions
|
||||
delivery_id = self.queue()
|
||||
db.set_user_role('viewer', 'user')
|
||||
async def report(*args):
|
||||
update_permissions({'stats': False}, 'viewer')
|
||||
return self.report
|
||||
def transport(recipient, rendered, message_id, before_data):
|
||||
before_data()
|
||||
self.fail('Report must not be sent after stats permission is revoked')
|
||||
with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
|
||||
await self.run_claim()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||
|
||||
async def test_blocked_expired_and_deleted_accounts_are_not_sent(self):
|
||||
for kind in ['blocked', 'expired', 'deleted']:
|
||||
with self.subTest(kind=kind):
|
||||
# Each subcase starts with a fresh account and confirmed subscription.
|
||||
db.create_user(kind, 'Example-Password123!', email=f'{kind}@example.test')
|
||||
account = db.get_user_by_username(kind)
|
||||
link_user(kind, f'jf-{kind}', 'http://jellyfin')
|
||||
store.request_confirmation(account, source_key('http://jellyfin'), f'jf-{kind}', time.time())
|
||||
store.confirm(store.subscription(account['id']), time.time())
|
||||
delivery_id = self.queue(store.subscription(account['id']), kind)
|
||||
with store.transaction() as conn:
|
||||
if kind == 'blocked': conn.execute('UPDATE users SET is_blocked=1 WHERE id=?', (account['id'],))
|
||||
elif kind == 'expired': conn.execute("UPDATE users SET expires_at='2000-01-01T00:00:00+00:00' WHERE id=?", (account['id'],))
|
||||
else: conn.execute('DELETE FROM users WHERE id=?', (account['id'],))
|
||||
with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
|
||||
await recaps.run_once()
|
||||
sender.assert_not_called(); report.assert_not_called()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||
|
||||
async def test_known_temporary_failure_retries_three_times_with_stable_id(self):
|
||||
delivery_id = self.queue()
|
||||
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('retry', 'SMTP 451')) as sender:
|
||||
for attempt in range(1, 4):
|
||||
await self.run_claim()
|
||||
row = self.delivery(delivery_id)
|
||||
self.assertEqual(row['attempts'], attempt)
|
||||
self.assertEqual(row['state'], 'failed' if attempt == 3 else 'retry')
|
||||
if attempt < 3:
|
||||
self.assertGreater(row['next_attempt_at'], time.time() + 250)
|
||||
with store.transaction() as conn:
|
||||
conn.execute('UPDATE email_recap_deliveries SET next_attempt_at=0 WHERE id=?', (delivery_id,))
|
||||
self.assertEqual(len(set(call.args[2] for call in sender.call_args_list)), 1)
|
||||
self.assertIsNone(store.claim_delivery(time.time()))
|
||||
|
||||
async def test_ambiguous_smtp_failure_never_automatically_retries(self):
|
||||
delivery_id = self.queue()
|
||||
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'Check mail logs')):
|
||||
await self.run_claim()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
|
||||
self.assertIsNone(store.claim_delivery(time.time() + 86400))
|
||||
|
||||
def test_stale_worker_claims_are_recovered_without_resending_uncertain_mail(self):
|
||||
delivery_id = self.queue()
|
||||
first = store.claim_delivery(time.time())
|
||||
second = store.claim_delivery(time.time() + 1801)
|
||||
self.assertNotEqual(first['claim'], second['claim'])
|
||||
self.assertFalse(store.begin_sending(first, time.time()))
|
||||
self.assertTrue(store.begin_sending(second, time.time()))
|
||||
store.claim_delivery(time.time() + 1801)
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
|
||||
store.finish(first, 'sent', 'Old worker', time.time())
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
|
||||
|
||||
async def test_partial_or_over_limit_report_is_not_emailed(self):
|
||||
delivery_id = self.queue()
|
||||
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(side_effect=HistoryLimitError('limit'))), patch.object(mail, 'send_email') as sender:
|
||||
await self.run_claim()
|
||||
sender.assert_not_called()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'failed')
|
||||
|
||||
|
||||
class RecapApiTests(RecapFixture, unittest.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
self.app = app
|
||||
self.client = TestClient(app)
|
||||
self.addCleanup(self.client.close)
|
||||
|
||||
def login(self, role='admin'):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {**self.user, 'role': role, 'features': {'stats': True}}
|
||||
|
||||
def test_authentication_roles_and_recipient_override(self):
|
||||
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 401)
|
||||
self.assertEqual(self.client.get('/profile/email-recaps').status_code, 401)
|
||||
self.login('user')
|
||||
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 403)
|
||||
self.assertEqual(self.client.get('/admin/email-recaps/preview').status_code, 403)
|
||||
self.assertEqual(self.client.post('/admin/email-recaps/test', json={}).status_code, 403)
|
||||
self.login()
|
||||
result = self.client.get('/admin/email-recaps')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.headers['cache-control'], 'no-store')
|
||||
self.assertNotIn('PRIVATE-STATS-KEY', result.text)
|
||||
result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'recipient_email': 'other@example.test'})
|
||||
self.assertEqual(result.status_code, 422)
|
||||
result = self.client.put('/profile/email-recaps', json={'enabled': False, 'user_id': 5})
|
||||
self.assertEqual(result.status_code, 422)
|
||||
|
||||
def test_url_and_schedule_validation_do_not_write_partial_settings(self):
|
||||
self.login()
|
||||
for value in ['javascript:alert(1)', 'https://user:secret@example.test', 'https://example.test/path', 'https://example.test?token=secret', 'https://example.test#token', 'https://example.test:0', 'https://example.test\\evil']:
|
||||
result = self.client.put('/admin/email-recaps', json={**self.config, 'public_url': value})
|
||||
self.assertEqual(result.status_code, 422, value)
|
||||
for field, value in [('day', 0), ('day', 29), ('hour', 24)]:
|
||||
self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, field: value}).status_code, 422)
|
||||
with patch.object(recaps, 'smtp_email_config_ready', return_value=(False, 'Email is disabled.')):
|
||||
self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, 'enabled': True}).status_code, 409)
|
||||
self.assertEqual(store.settings()['public_url'], self.config['public_url'])
|
||||
self.assertFalse(store.settings()['enabled'])
|
||||
|
||||
def test_preview_uses_own_report_and_test_requires_confirmed_email(self):
|
||||
self.login()
|
||||
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email') as sender:
|
||||
result = self.client.get('/admin/email-recaps/preview')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
|
||||
self.assertNotIn('PRIVATE-TOKEN', result.text)
|
||||
sender.assert_not_called()
|
||||
payload = {'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': self.report['month']}
|
||||
self.assertEqual(self.client.post('/admin/email-recaps/test', json=payload).status_code, 409)
|
||||
self.subscribe()
|
||||
with patch.object(mail, 'send_email') as sender:
|
||||
first = self.client.post('/admin/email-recaps/test', json=payload)
|
||||
second = self.client.post('/admin/email-recaps/test', json=payload)
|
||||
self.assertEqual(first.status_code, 202)
|
||||
self.assertEqual(first.json()['id'], second.json()['id'])
|
||||
sender.assert_not_called()
|
||||
|
||||
def test_partial_month_test_rejected_and_public_get_does_not_mutate(self):
|
||||
self.login(); sub, token = self.subscribe()
|
||||
result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': datetime.now(timezone.utc).strftime('%Y-%m')})
|
||||
self.assertEqual(result.status_code, 422)
|
||||
self.assertEqual(self.client.get('/email-recaps/confirm').status_code, 405)
|
||||
result = self.client.post('/email-recaps/check', json={'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
|
||||
|
||||
|
||||
class RecapEmailTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runtime = runtime()
|
||||
patched = patch.object(mail, 'get_runtime_settings', return_value=self.runtime)
|
||||
patched.start(); self.addCleanup(patched.stop)
|
||||
self.rendered = mail.render_recap(fixture_report(), 'Viewer', 'https://beta.example.test', 'https://beta.example.test/email-recaps#action=unsubscribe&token=fixture')
|
||||
|
||||
def fake_smtp(self):
|
||||
smtp = MagicMock()
|
||||
smtp.mail.return_value = (250, b'OK')
|
||||
smtp.rcpt.return_value = (250, b'OK')
|
||||
smtp.data.return_value = (250, b'Accepted')
|
||||
return smtp
|
||||
|
||||
def test_render_escapes_names_and_titles_and_includes_no_artwork_credentials(self):
|
||||
report = fixture_report()
|
||||
report['top_titles'][0]['title'] = '<img src=x onerror=alert(1)>'
|
||||
rendered = mail.render_recap(report, '<script>alert(1)</script>', 'https://beta.example.test', 'https://beta.example.test/email-recaps#token=example')
|
||||
self.assertNotIn('<script>', rendered['body_html'])
|
||||
self.assertNotIn('<img src=x', rendered['body_html'])
|
||||
self.assertIn('<script>', rendered['body_html'])
|
||||
self.assertNotIn('PRIVATE-TOKEN', str(rendered))
|
||||
self.assertIn('Unsubscribe', rendered['body_text'])
|
||||
self.assertIn('UTC', rendered['body_text'])
|
||||
self.assertIn('1,500', rendered['body_html'])
|
||||
|
||||
def test_mailbox_validation_rejects_injection_and_multiple_recipients(self):
|
||||
for value in ['a@example.test\r\nBcc:b@example.test', 'a@example.test,b@example.test', 'Name <a@example.test>', 'x@', 'a;b@example.test']:
|
||||
self.assertIsNone(mail.valid_email(value))
|
||||
|
||||
def test_smtp_acceptance_survives_quit_error_and_preserves_mime_message_id(self):
|
||||
smtp = self.fake_smtp()
|
||||
smtp.quit.side_effect = smtplib.SMTPServerDisconnected('after acceptance')
|
||||
before = MagicMock()
|
||||
with patch.object(mail.smtplib, 'SMTP', return_value=smtp):
|
||||
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>', before)
|
||||
before.assert_called_once()
|
||||
message = BytesParser(policy=policy.default).parsebytes(smtp.data.call_args.args[0])
|
||||
self.assertEqual(message['Message-ID'], '<stable@example.test>')
|
||||
self.assertEqual(message['To'], 'viewer@example.test')
|
||||
self.assertIsNone(message['Bcc'])
|
||||
self.assertIn('1,500', message.get_body(('plain',)).get_content())
|
||||
self.assertIn('<!doctype html>', message.get_body(('html',)).get_content())
|
||||
|
||||
def test_temporary_permanent_and_ambiguous_delivery_failures(self):
|
||||
for operation, failure, expected in [
|
||||
('mail', (451, b'temporary PRIVATE-KEY'), 'retry'), ('rcpt', (550, b'bad recipient'), 'failed'),
|
||||
('data', (451, b'retry'), 'retry'), ('data', smtplib.SMTPServerDisconnected('lost after DATA'), 'unknown'),
|
||||
('rcpt', smtplib.SMTPServerDisconnected('lost before DATA'), 'retry')]:
|
||||
smtp = self.fake_smtp()
|
||||
if isinstance(failure, Exception): getattr(smtp, operation).side_effect = failure
|
||||
else: getattr(smtp, operation).return_value = failure
|
||||
with self.subTest(operation=operation, expected=expected), patch.object(mail.smtplib, 'SMTP', return_value=smtp):
|
||||
with self.assertRaises(mail.DeliveryError) as exc:
|
||||
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>')
|
||||
self.assertEqual(exc.exception.state, expected)
|
||||
self.assertNotIn('PRIVATE-KEY', exc.exception.detail)
|
||||
|
||||
def test_consent_cancellation_happens_before_smtp_data(self):
|
||||
smtp = self.fake_smtp()
|
||||
with patch.object(mail.smtplib, 'SMTP', return_value=smtp), self.assertRaises(mail.DeliveryCancelled):
|
||||
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>', MagicMock(side_effect=mail.DeliveryCancelled))
|
||||
smtp.data.assert_not_called()
|
||||
|
||||
def test_real_smtp_is_captured_locally_without_external_delivery(self):
|
||||
messages = []
|
||||
class Capture(socketserver.StreamRequestHandler):
|
||||
def handle(self):
|
||||
self.wfile.write(b'220 local capture\r\n')
|
||||
while line := self.rfile.readline():
|
||||
command = line.split(b' ', 1)[0].strip().upper()
|
||||
if command in (b'EHLO', b'HELO'):
|
||||
self.wfile.write(b'250-localhost\r\n250 SIZE 1000000\r\n')
|
||||
elif command == b'DATA':
|
||||
self.wfile.write(b'354 Send content\r\n')
|
||||
data = []
|
||||
while (part := self.rfile.readline()) != b'.\r\n':
|
||||
if not part: return
|
||||
data.append(part[1:] if part.startswith(b'..') else part)
|
||||
messages.append(b''.join(data))
|
||||
self.wfile.write(b'250 Captured\r\n')
|
||||
elif command == b'QUIT':
|
||||
self.wfile.write(b'221 Bye\r\n'); return
|
||||
else:
|
||||
self.wfile.write(b'250 OK\r\n')
|
||||
with socketserver.TCPServer(('127.0.0.1', 0), Capture) as server:
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.runtime.magent_notify_email_smtp_port = server.server_address[1]
|
||||
try:
|
||||
mail.send_email('viewer@example.test', self.rendered, '<local-capture@example.test>')
|
||||
finally:
|
||||
server.shutdown(); thread.join(timeout=5)
|
||||
self.assertEqual(len(messages), 1)
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(messages[0])
|
||||
self.assertEqual(parsed['Message-ID'], '<local-capture@example.test>')
|
||||
self.assertIn('Severance', parsed.get_body(('html',)).get_content())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class OnDemandReportTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_new_confirmation_defaults_to_manual_without_changing_schedule(self):
|
||||
with patch.object(mail, 'send_email'):
|
||||
result = await recaps.subscribe(self.user)
|
||||
self.assertFalse(result['automatic_monthly'])
|
||||
self.assertFalse(store.settings()['enabled'])
|
||||
self.assertEqual(result['state'], 'pending')
|
||||
with self.assertRaises(recaps.RecapError):
|
||||
recaps.queue_personal(self.user, None, 'pending')
|
||||
|
||||
async def test_manual_current_month_delivers_with_monthly_schedule_off(self):
|
||||
sub, _ = self.subscribe()
|
||||
store.set_automatic(self.user['id'], False)
|
||||
month = datetime.now(timezone.utc).strftime('%Y-%m')
|
||||
queued = recaps.queue_personal(self.user, month, 'manual-1')
|
||||
self.assertEqual(recaps.queue_personal(self.user, month, 'manual-1')['id'], queued['id'])
|
||||
report = {**self.report, **month_periods(month, datetime.now(timezone.utc))}
|
||||
def send(recipient, rendered, message_id, before_data):
|
||||
before_data()
|
||||
self.assertEqual(recipient, self.user['email'])
|
||||
self.assertIn('so far', rendered['subject'])
|
||||
self.assertNotIn('[Test]', rendered['subject'])
|
||||
with patch.object(recaps, 'get_monthly_report', new_callable=AsyncMock, return_value=report), patch.object(mail, 'send_email', side_effect=send):
|
||||
await recaps.process_delivery(store.claim_delivery(time.time()))
|
||||
self.assertEqual(self.delivery(queued['id'])['state'], 'sent')
|
||||
self.assertFalse(store.settings()['enabled'])
|
||||
self.assertFalse(store.subscription(self.user['id'])['automatic_monthly'])
|
||||
with self.assertRaises(recaps.RecapError) as error:
|
||||
recaps.queue_personal(self.user, month, 'manual-2')
|
||||
self.assertEqual(error.exception.status, 429)
|
||||
|
||||
async def test_automatic_opt_out_cancels_scheduled_but_keeps_manual(self):
|
||||
sub, _ = self.subscribe()
|
||||
with store.transaction() as conn:
|
||||
scheduled = store._enqueue(conn, sub, self.report['month'], 'scheduled', 'scheduled-fixture', self.config['public_url'], time.time())
|
||||
manual = recaps.queue_personal(self.user, None, 'manual')
|
||||
store.set_automatic(self.user['id'], False)
|
||||
self.assertEqual(self.delivery(scheduled)['state'], 'cancelled')
|
||||
self.assertEqual(self.delivery(manual['id'])['state'], 'queued')
|
||||
self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
|
||||
now = datetime.now(timezone.utc)
|
||||
store.save_settings({**self.config, 'enabled': True}, now)
|
||||
self.assertEqual(store.enqueue_due(now + timedelta(days=40)), 0)
|
||||
|
||||
async def test_changed_identity_cancels_manual_delivery(self):
|
||||
self.subscribe()
|
||||
queued = recaps.queue_personal(self.user, None, 'manual')
|
||||
delivery = store.claim_delivery(time.time())
|
||||
db.set_user_email('viewer', 'changed@example.test')
|
||||
with patch.object(mail, 'send_email') as send:
|
||||
await recaps.process_delivery(delivery)
|
||||
send.assert_not_called()
|
||||
self.assertEqual(self.delivery(queued['id'])['state'], 'cancelled')
|
||||
|
||||
async def test_regular_user_can_only_send_to_self(self):
|
||||
self.subscribe()
|
||||
app = FastAPI(); app.include_router(router.router)
|
||||
app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user', 'features': {'stats': True}}
|
||||
client = TestClient(app)
|
||||
body = {'month': self.report['month'], 'request_id': '11111111-1111-4111-8111-111111111111'}
|
||||
for extra in [{'email': 'other@example.test'}, {'user_id': 42}, {'kind': 'scheduled'}]:
|
||||
self.assertEqual(client.post('/profile/email-recaps/send', json={**body, **extra}).status_code, 422)
|
||||
self.assertEqual(client.post('/profile/email-recaps/send', json=body).status_code, 202)
|
||||
response = client.get('/profile/email-recaps')
|
||||
self.assertEqual(response.headers['cache-control'], 'no-store')
|
||||
self.assertEqual(len(response.json()['deliveries']), 1)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user