Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f4071c970 | ||
|
|
a6a4a9aa24 | ||
|
|
91a950a3b0 | ||
|
|
2525a9eb25 | ||
|
|
8a6fe71446 | ||
|
|
6ab79efc35 | ||
|
|
f852e7c941 | ||
|
|
5639dbcb83 | ||
|
|
a6d1c73837 | ||
|
|
aed1bf9256 | ||
|
|
05a540ecbb | ||
|
|
d75f36c691 | ||
|
|
3465343a69 | ||
|
|
4ba1a5763e | ||
|
|
dd51332f3c | ||
|
|
6a84e68a03 | ||
|
|
c194db167a | ||
|
|
e232335ca9 | ||
|
|
8df02fdfd7 | ||
|
|
ce756c1a65 | ||
|
|
b3b83bda4f | ||
|
|
0eecab4e0e | ||
|
|
765b0d2033 | ||
|
|
1debf6053c | ||
|
|
d7e5c75cb1 | ||
|
|
dcd8082c8d | ||
|
|
e3332bec1f | ||
|
|
f57058eb26 | ||
|
|
8734f461bb | ||
|
|
835d9c8de3 | ||
|
|
956fb3ecb1 | ||
|
|
4f7853b17b | ||
|
|
de25255ea8 | ||
|
|
38169b881e | ||
|
|
52c85daae3 | ||
|
|
df6fe58278 | ||
|
|
ec0a866ef3 | ||
|
|
e2be8b3872 | ||
|
|
b286ca3c42 | ||
|
|
6e473fd0a7 | ||
|
|
9856c7fb90 | ||
|
|
df651eb312 | ||
|
|
77f2c1b42a | ||
|
|
b310e86f80 | ||
|
|
747a330b19 | ||
|
|
b0f8c89db7 | ||
|
|
e014baadc3 | ||
|
|
1979e02cde | ||
|
|
333a799e21 | ||
|
|
437836243c | ||
|
|
e7e4c9eff3 | ||
|
|
12611a9819 | ||
|
|
2976145dd8 | ||
|
|
a3b5759708 | ||
|
|
13edcb8136 | ||
|
|
131b5fc5c7 | ||
|
|
bd1f2cb1cb | ||
|
|
edca300d27 | ||
|
|
4034a8f72a | ||
|
|
0637860b95 | ||
|
|
697fc235ee | ||
|
|
62ee07f92b | ||
|
|
458ef53f47 | ||
|
|
c2685f43a7 | ||
|
|
98d8b197a9 | ||
|
|
625f9ad7f0 | ||
|
|
bd668715a3 | ||
|
|
212ac560ec | ||
|
|
a32928b1c5 | ||
|
|
74c49fad5b | ||
|
|
009bb35032 | ||
|
|
8d720de500 | ||
|
|
1851fa9753 | ||
|
|
dec1dd902c | ||
|
|
4d67567d4c | ||
|
|
b5e4c57e93 | ||
|
|
f65e1b114c | ||
|
|
32bfa20ab7 | ||
|
|
0b59289a2e | ||
|
|
ded794a819 | ||
|
|
c6d449dc17 | ||
|
|
06d944c9d9 | ||
|
|
0ac53b7f59 | ||
|
|
2dbe11e6bc | ||
|
|
3aac40ba0f | ||
|
|
b6c48a0be7 | ||
|
|
3fc52f70c7 | ||
|
|
6391fbfd81 | ||
|
|
0ed22dd315 | ||
|
|
7ed0f4b103 | ||
|
|
b3c41f6dea | ||
|
|
976d24217b | ||
|
|
c49a149cfd | ||
|
|
5de14b1cb7 | ||
|
|
16876e1cf0 | ||
|
|
c7a56f2525 | ||
|
|
87a4aae246 | ||
|
|
e58614305e | ||
|
|
2adbed7259 | ||
|
|
393b8c2a88 | ||
|
|
b0eff9ffcf | ||
|
|
ae6cee5d0b | ||
|
|
906a777b95 | ||
|
|
ec8145a58a | ||
|
|
f8770cb44a | ||
|
|
0e04d219a0 | ||
|
|
3402e53c31 | ||
|
|
ecf9b230c1 | ||
|
|
8f810e0f36 | ||
|
|
82d87d968e | ||
|
|
372f4a1bfc | ||
|
|
547ed754e6 | ||
|
|
a55369190b | ||
|
|
ee81749b43 | ||
|
|
9dfea25d56 | ||
|
|
963506d098 | ||
|
|
02245d365e | ||
|
|
2cbd9fe73f | ||
|
|
9db32481bd | ||
|
|
3815dfea60 | ||
|
|
c073581639 | ||
|
|
06a000bb06 | ||
|
|
391cd41d71 | ||
|
|
96fc43365f | ||
|
|
655e2f8158 |
@@ -0,0 +1 @@
|
||||
0803262237
|
||||
@@ -1,5 +1,14 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
.venv/
|
||||
**/.pytest_cache/
|
||||
stitch_magent_media_operations_redesign/
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
bootstrap-admin.json
|
||||
release.tar
|
||||
*.log
|
||||
data/*
|
||||
!data/branding/
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Provision this as .env on the beta host. Do not copy production secrets or data.
|
||||
APP_NAME=Magent Beta
|
||||
CORS_ALLOW_ORIGIN=https://beta.grizzlyflix.co.nz
|
||||
MAGENT_APPLICATION_URL=https://beta.grizzlyflix.co.nz
|
||||
MAGENT_API_URL=https://beta.grizzlyflix.co.nz/api
|
||||
SQLITE_PATH=/app/data/magent.db
|
||||
LOG_FILE=/app/data/magent.log
|
||||
LOG_FORMAT=json
|
||||
|
||||
JWT_SECRET=replace-with-an-independent-beta-secret-of-at-least-32-characters
|
||||
SETTINGS_ENCRYPTION_KEY=replace-with-an-independent-valid-fernet-key
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=replace-with-a-strong-beta-bootstrap-password
|
||||
|
||||
AUTH_COOKIE_NAME=magent_beta_auth
|
||||
AUTH_STATE_COOKIE_NAME=magent_beta_logged_in
|
||||
AUTH_COOKIE_DOMAIN=beta.grizzlyflix.co.nz
|
||||
AUTH_COOKIE_SECURE=true
|
||||
AUTH_COOKIE_SAMESITE=strict
|
||||
API_DOCS_ENABLED=false
|
||||
@@ -0,0 +1,18 @@
|
||||
# Copy to .env for local development. Never reuse these example values in a deployed environment.
|
||||
APP_NAME=Magent
|
||||
CORS_ALLOW_ORIGIN=http://localhost:3000
|
||||
MAGENT_APPLICATION_URL=http://localhost:3000
|
||||
MAGENT_API_URL=http://localhost:8000
|
||||
SQLITE_PATH=/app/data/magent.db
|
||||
LOG_FILE=/app/data/magent.log
|
||||
LOG_FORMAT=text
|
||||
|
||||
# Generate independent values as documented in README.md.
|
||||
JWT_SECRET=replace-with-at-least-32-random-characters
|
||||
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=replace-with-a-strong-bootstrap-password
|
||||
|
||||
AUTH_COOKIE_SECURE=false
|
||||
AUTH_COOKIE_SAMESITE=strict
|
||||
API_DOCS_ENABLED=false
|
||||
@@ -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
|
||||
@@ -0,0 +1,106 @@
|
||||
name: Magent CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- beta
|
||||
- main
|
||||
- prod
|
||||
pull_request:
|
||||
branches:
|
||||
- beta
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: magent-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "24"
|
||||
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend quality gate
|
||||
run: bash scripts/ci_backend_quality_gate.sh
|
||||
|
||||
- name: Verify generated build metadata
|
||||
run: python scripts/verify_build_metadata.py
|
||||
|
||||
- name: Audit frontend production dependencies
|
||||
working-directory: frontend
|
||||
run: npm audit --omit=dev --package-lock-only --audit-level=high
|
||||
|
||||
- name: Lint frontend
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Check frontend formatting
|
||||
working-directory: frontend
|
||||
run: npm run format:check
|
||||
|
||||
- name: Type-check frontend
|
||||
working-directory: frontend
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test frontend
|
||||
working-directory: frontend
|
||||
run: npm test
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Validate Compose configuration
|
||||
run: |
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml config --quiet
|
||||
|
||||
- name: Build and smoke-test container
|
||||
run: bash scripts/ci_container_smoke.sh
|
||||
|
||||
deploy-beta:
|
||||
if: github.ref_name == 'beta'
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure SSH key
|
||||
env:
|
||||
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}"
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy beta to AMS-DEV01
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
|
||||
run: bash scripts/deploy_beta_ams_dev01.sh
|
||||
+13
@@ -1,5 +1,7 @@
|
||||
.env
|
||||
bootstrap-admin.json
|
||||
.venv/
|
||||
.security-test-venv*/
|
||||
data/
|
||||
!data/branding/
|
||||
!data/branding/**
|
||||
@@ -7,6 +9,17 @@ backend/__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
backend/.pytest_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
*.tsbuildinfo
|
||||
*.log
|
||||
**/.pytest_cache/
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
|
||||
NEXT_PUBLIC_API_BASE=/api
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --include=dev
|
||||
|
||||
COPY frontend/app ./app
|
||||
COPY frontend/public ./public
|
||||
COPY frontend/next-env.d.ts ./next-env.d.ts
|
||||
COPY frontend/next.config.js ./next.config.js
|
||||
COPY frontend/proxy.ts ./proxy.ts
|
||||
COPY frontend/tsconfig.json ./tsconfig.json
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
NODE_ENV=production
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG MAGENT_UID=1000
|
||||
ARG MAGENT_GID=1000
|
||||
RUN groupadd --gid ${MAGENT_GID} magent \
|
||||
&& useradd --uid ${MAGENT_UID} --gid magent --create-home --shell /usr/sbin/nologin magent
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY --chown=magent:magent backend/app ./app
|
||||
COPY --chown=magent:magent data/branding /app/data/branding
|
||||
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/.next /app/frontend/.next
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/package.json /app/frontend/package.json
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/proxy.ts /app/frontend/proxy.ts
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
|
||||
|
||||
COPY --chown=magent:magent docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
|
||||
|
||||
RUN chown -R magent:magent /app
|
||||
USER magent:magent
|
||||
|
||||
EXPOSE 3000 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
|
||||
CMD curl --fail --silent --show-error http://127.0.0.1:8000/health >/dev/null \
|
||||
&& curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null \
|
||||
|| exit 1
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
||||
@@ -0,0 +1,71 @@
|
||||
# Production
|
||||
|
||||
Magent runs as one combined frontend/API image: `rephl3xnz/magent`.
|
||||
The root `Dockerfile` is the supported build entry point. Source releases come
|
||||
from `main`; use `prod-<short-commit>` tags to identify an exact release.
|
||||
|
||||
## Live deployment
|
||||
|
||||
- Host: GRZ-DKR01 (`10.30.1.81`).
|
||||
- Container and Compose service: `magent`; Compose project: `arrstack`.
|
||||
- Compose file: `/home/zak/grizzlystack/arrstack/docker-compose.yml`.
|
||||
- Persistent data: `/home/zak/grizzlystack/arrstack/magent/data` → `/app/data`.
|
||||
- Public URL: `https://magent.grizzlyflix.co.nz`.
|
||||
- Caddy runs on AMS-CAD01 and proxies production to `10.30.1.81:3002`.
|
||||
- Beta remains separate on AMS-DEV01. Do not overwrite it or change its routes.
|
||||
|
||||
## Release checklist
|
||||
|
||||
1. Run the backend tests and frontend production build. Review only the intended
|
||||
changes, then commit and push `main`.
|
||||
The repository workflow verifies `main` but intentionally does not deploy it;
|
||||
production changes require the remaining explicit release steps below.
|
||||
2. Build from a clean source export using the root Dockerfile. Never include
|
||||
`.env`, databases or bootstrap credentials in the build context.
|
||||
3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub.
|
||||
Confirm their digests match.
|
||||
4. Pull the new image before stopping production. Keep the old image under a
|
||||
rollback tag and back up the current Compose configuration.
|
||||
5. Briefly stop only `magent`, then back up its complete data directory so SQLite
|
||||
and its WAL files are consistent. Protect backups: they contain private data.
|
||||
6. Recreate only this service with `docker compose -p arrstack -f
|
||||
/home/zak/grizzlystack/arrstack/docker-compose.yml up -d --no-deps --no-build magent`.
|
||||
Confirm that Compose selects the intended image before running this command.
|
||||
7. Check container health, the API `/health` endpoint, public login, the changed
|
||||
feature, database integrity and account counts. Do not trigger bulk permission
|
||||
changes, email sends or user imports as a deployment smoke test.
|
||||
|
||||
Include browser-origin POST checks for both `/api/auth/login` and
|
||||
`/api/auth/jellyfin/login`: an empty form with `Origin` set to the public URL
|
||||
must reach input validation (422), while an unrelated origin must return 403.
|
||||
GET-only login/health checks do not detect origin-policy lockouts. Set
|
||||
`CORS_ALLOW_ORIGIN` to the exact public origin; the state-change guard also
|
||||
accepts the explicitly configured Hosting & proxy public URL, never a URL
|
||||
inferred from request Host or forwarded headers.
|
||||
|
||||
For rollback, select the saved image and recreate only Magent. Restore data only
|
||||
if needed; doing so can discard activity since the backup. Never restore a whole
|
||||
shared Compose or Caddy file without checking for unrelated changes first.
|
||||
|
||||
## Build metadata
|
||||
|
||||
`.build_number` and `backend/app/build_info.py` currently hold the same legacy
|
||||
display build number as the frontend package files. `.env` should have exactly
|
||||
one `BUILD_NUMBER` assignment, not a history of previous releases. Docker release
|
||||
tags identify the deployed source commit independently of this display value.
|
||||
|
||||
`scripts/process1.ps1` is a local development workflow: it updates metadata,
|
||||
runs tests, rebuilds local Docker, and can commit changes/send Discord messages.
|
||||
It is **not** the production deployment command. Its build-number helper can be
|
||||
tested safely with `powershell -File scripts/test_env_build_number.ps1`.
|
||||
|
||||
## Fresh instances and historical notes
|
||||
|
||||
`scripts/prepare_production_settings.py` exports only allowlisted connection and
|
||||
SMTP settings for a fresh instance. Do not use it to replace a live database.
|
||||
`docker-compose.production.yml` is the separate fresh-instance template, not the
|
||||
live GRZ-DKR01 Compose file. `docker-compose.hub.yml` is the generic Docker Hub
|
||||
template; `docker-compose.yml` builds locally; `docker-compose.beta.yml` serves beta.
|
||||
|
||||
The temporary AMS-DEV01 setup and coming-soon cutover are retained under
|
||||
[archived cutover notes](docs/archive/production-cutover-2026-09-07.md).
|
||||
@@ -1,10 +1,10 @@
|
||||
# Magent
|
||||
|
||||
Magent 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.
|
||||
Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
|
||||
|
||||
## How it works
|
||||
|
||||
1) Requests are pulled from Jellyseerr and stored locally.
|
||||
1) Requests are pulled from Seerr 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.
|
||||
@@ -14,7 +14,7 @@ Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services.
|
||||
|
||||
- Request search by title/year or request ID.
|
||||
- Recent requests list with posters and status.
|
||||
- Timeline view across Jellyseerr, Arr, Prowlarr, qBittorrent, Jellyfin.
|
||||
- Timeline view across Seerr, 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.
|
||||
@@ -23,6 +23,8 @@ Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services.
|
||||
- Local database for speed and audit history.
|
||||
- Users and access control (admin vs user, block access).
|
||||
- Local account password changes via "My profile".
|
||||
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
|
||||
- Admin review and confirmation of account IDs across Jellyfin, Seerr, Jellystat and Magent. See [user identities](docs/user-identities.md).
|
||||
- Docker-first deployment for easy hosting.
|
||||
|
||||
## Quick start (Docker - primary)
|
||||
@@ -64,10 +66,11 @@ 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"
|
||||
JWT_SECRET="replace-with-at-least-32-random-characters"
|
||||
SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
|
||||
JWT_EXP_MINUTES="120"
|
||||
ADMIN_USERNAME="set-a-real-admin-username"
|
||||
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
@@ -112,10 +115,11 @@ $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"
|
||||
$env:JWT_SECRET="replace-with-at-least-32-random-characters"
|
||||
$env:SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
|
||||
$env:JWT_EXP_MINUTES="120"
|
||||
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
||||
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
```
|
||||
|
||||
### Frontend (Next.js)
|
||||
@@ -132,6 +136,19 @@ Admin panel: http://localhost:3000/admin
|
||||
|
||||
Login uses the admin credentials above (or any other local user you create in SQLite).
|
||||
|
||||
### Local quality checks
|
||||
|
||||
```bash
|
||||
bash scripts/ci_backend_quality_gate.sh
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Public Hosting Notes
|
||||
|
||||
The frontend proxies `/api/*` to the backend container. Set:
|
||||
@@ -141,6 +158,46 @@ The frontend proxies `/api/*` to the backend container. Set:
|
||||
|
||||
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
||||
|
||||
## Gitea CI/CD
|
||||
|
||||
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
|
||||
|
||||
- Push to `beta`: runs the complete quality gate and deploys the isolated beta environment to `AMS-DEV01`.
|
||||
- Push to `main` or `prod`: runs the same verification without automatically changing production.
|
||||
- Production releases are tagged from `main` and deployed to `GRZ-DKR01` using the checklist in `PRODUCTION.md`.
|
||||
|
||||
The beta deploy step ships tracked repository files over SSH, preserves beta's own `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
|
||||
|
||||
- `http://127.0.0.1:8000/health`
|
||||
- `http://127.0.0.1:3000/login`
|
||||
|
||||
Configure these Gitea Actions secrets before enabling the deploy job:
|
||||
|
||||
The existing `PROD_*` names are retained for compatibility, but this workflow uses them only for the isolated beta host deployment.
|
||||
|
||||
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
|
||||
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
|
||||
- `PROD_SSH_USER`: target user, for example `zak`.
|
||||
- `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts.
|
||||
|
||||
Beta always deploys to the isolated `/home/<deployment-user>/magent-beta` directory; the production path secret is intentionally ignored.
|
||||
|
||||
## Security and data handling
|
||||
|
||||
Generate independent signing and settings-encryption secrets before first startup:
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
```
|
||||
|
||||
- `JWT_SECRET` must contain at least 32 characters. Access sessions expire after 120 minutes by default and are revoked after logout, password, role, or blocked-state changes.
|
||||
- `SETTINGS_ENCRYPTION_KEY` protects service API keys, SMTP credentials, webhooks, and private keys stored in SQLite. Keep it in `.env`, outside the database and its backups. If omitted, Magent derives a migration-compatible key from `JWT_SECRET`; a dedicated key is recommended.
|
||||
- Invite secrets are stored as one-way hashes. Existing invite links continue to work after migration, but the admin UI cannot reveal an old link. Copy a link when it is created, or generate a replacement link later; replacement immediately invalidates the prior link.
|
||||
- Magent encrypts sensitive settings, not the entire SQLite database. Request metadata, account records, logs, the `data/` volume, and backups should live on encrypted host storage with access restricted to the deployment account.
|
||||
- `REQUESTS_CLEANUP_DAYS` controls routine request-history retention (90 days by default). Account deletion removes authentication and subscription records and anonymizes retained request and portal history.
|
||||
- Production and beta cookies require HTTPS and use `SameSite=Strict`. Keep the backend port bound to loopback and publish the frontend only through the intended reverse proxy.
|
||||
|
||||
## History endpoints
|
||||
|
||||
- `GET /requests/{id}/history?limit=10` recent snapshots
|
||||
@@ -160,7 +217,7 @@ If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BAS
|
||||
|
||||
### No recent requests
|
||||
|
||||
- Confirm Jellyseerr credentials in Settings.
|
||||
- Confirm Seerr credentials in Settings.
|
||||
- Run a full sync from Settings -> Requests.
|
||||
|
||||
### Docker images not updating
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
@@ -26,7 +26,7 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
|
||||
recommendations.append(
|
||||
TriageRecommendation(
|
||||
action_id="readd_to_arr",
|
||||
title="Add it to the library queue",
|
||||
title="Push to Sonarr/Radarr",
|
||||
reason="Sonarr/Radarr has not created the entry for this request.",
|
||||
risk="medium",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+201
-6
@@ -1,19 +1,152 @@
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from .db import get_user_by_username
|
||||
from .security import safe_decode_token, TokenError
|
||||
from .config import settings
|
||||
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
||||
from .network_security import request_trusts_forwarded_headers
|
||||
from .security import TokenError, safe_decode_token, verify_password
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
|
||||
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)
|
||||
|
||||
|
||||
def _extract_client_ip(request: Request) -> str:
|
||||
direct_host = request.client.host if request.client else None
|
||||
if request_trusts_forwarded_headers(direct_host):
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
return parts[0]
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
if direct_host:
|
||||
return direct_host
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _cookie_settings() -> dict[str, Any]:
|
||||
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
|
||||
if samesite not in {"lax", "strict", "none"}:
|
||||
samesite = "lax"
|
||||
return {
|
||||
"secure": bool(settings.auth_cookie_secure),
|
||||
"httponly": True,
|
||||
"samesite": samesite,
|
||||
"domain": settings.auth_cookie_domain or None,
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
|
||||
def _state_cookie_settings() -> dict[str, Any]:
|
||||
cookie = _cookie_settings()
|
||||
cookie["httponly"] = False
|
||||
return cookie
|
||||
|
||||
|
||||
def set_auth_cookies(response: Response, token: str) -> None:
|
||||
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
|
||||
response.set_cookie(
|
||||
settings.auth_cookie_name,
|
||||
token,
|
||||
max_age=max_age,
|
||||
**_cookie_settings(),
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.auth_state_cookie_name,
|
||||
"1",
|
||||
max_age=max_age,
|
||||
**_state_cookie_settings(),
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response) -> None:
|
||||
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
|
||||
response.delete_cookie(
|
||||
settings.auth_state_cookie_name,
|
||||
path="/",
|
||||
domain=settings.auth_cookie_domain or None,
|
||||
)
|
||||
|
||||
|
||||
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
return auth_header.split(" ", 1)[1].strip()
|
||||
if oauth_token:
|
||||
return oauth_token
|
||||
cookie_token = request.cookies.get(settings.auth_cookie_name)
|
||||
if isinstance(cookie_token, str) and cookie_token.strip():
|
||||
return cookie_token.strip()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
|
||||
if not isinstance(user, dict):
|
||||
return "local"
|
||||
provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
||||
if provider != "local":
|
||||
return provider
|
||||
password_hash = user.get("password_hash")
|
||||
if isinstance(password_hash, str) and password_hash:
|
||||
if verify_password("jellyfin-user", password_hash):
|
||||
return "jellyfin"
|
||||
if verify_password("jellyseerr-user", password_hash):
|
||||
return "jellyseerr"
|
||||
return provider
|
||||
|
||||
|
||||
def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(user, dict):
|
||||
return {}
|
||||
resolved_provider = resolve_user_auth_provider(user)
|
||||
stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
||||
if resolved_provider != stored_provider:
|
||||
username = str(user.get("username") or "").strip()
|
||||
if username:
|
||||
set_user_auth_provider(username, resolved_provider)
|
||||
refreshed_user = get_user_by_username(username)
|
||||
if refreshed_user:
|
||||
user = refreshed_user
|
||||
normalized = dict(user)
|
||||
normalized["auth_provider"] = resolved_provider
|
||||
normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
|
||||
normalized["password_provider"] = (
|
||||
resolved_provider if resolved_provider in {"local", "jellyfin"} else None
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _load_current_user_from_token(
|
||||
token: str,
|
||||
request: Optional[Request] = None,
|
||||
allowed_token_types: Optional[set[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
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:
|
||||
@@ -24,15 +157,77 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
|
||||
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)
|
||||
user_agent = request.headers.get("user-agent", "unknown")
|
||||
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
|
||||
|
||||
File diff suppressed because one or more lines are too long
+398
-11
@@ -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,20 +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)
|
||||
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()
|
||||
return response.json()
|
||||
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]:
|
||||
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,
|
||||
)
|
||||
@@ -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 (
|
||||
"Grizzlyflix returned possible matches. Magent still needs to check the exact title and file."
|
||||
if available
|
||||
else "Grizzlyflix did not find this title in its library search."
|
||||
)
|
||||
|
||||
|
||||
class JellyfinClient(ApiClient):
|
||||
@@ -10,51 +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}
|
||||
headers = self._emby_headers()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
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()
|
||||
return response.json()
|
||||
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()
|
||||
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 = self._emby_headers()
|
||||
params = {"Recursive": "true" if recursive else "false"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
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,10 +61,65 @@ class JellyseerrClient(ApiClient):
|
||||
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
||||
|
||||
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
||||
# Seerr rejects the `+` encoding that standard query builders use for
|
||||
# spaces. Build this query explicitly so multi-word titles are sent as
|
||||
# percent-encoded values.
|
||||
encoded_query = quote(query, safe="")
|
||||
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
|
||||
|
||||
async def get_service_settings(self, media_type: str) -> Optional[Any]:
|
||||
service = "sonarr" if media_type == "tv" else "radarr"
|
||||
return await self.get(f"/api/v1/settings/{service}")
|
||||
|
||||
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(
|
||||
"/api/v1/search",
|
||||
"/api/v1/user",
|
||||
params={
|
||||
"query": query,
|
||||
"page": page,
|
||||
"take": take,
|
||||
"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,6 +1,64 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
from .base import ApiClient
|
||||
import logging
|
||||
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):
|
||||
@@ -8,6 +66,7 @@ class QBittorrentClient(ApiClient):
|
||||
super().__init__(base_url, None)
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.username and self.password)
|
||||
@@ -21,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
|
||||
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()
|
||||
return response.json()
|
||||
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
|
||||
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()
|
||||
return response.text.strip()
|
||||
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
|
||||
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")
|
||||
@@ -59,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")
|
||||
|
||||
@@ -71,8 +199,20 @@ 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]
|
||||
self.logger.warning(
|
||||
"qBittorrent add_torrent_url invoked: category=%s host=%s",
|
||||
category,
|
||||
url_host or "unknown",
|
||||
)
|
||||
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,13 @@ 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}")
|
||||
|
||||
async def get_movies(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/movie")
|
||||
|
||||
@@ -19,11 +26,34 @@ 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")
|
||||
|
||||
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,
|
||||
@@ -31,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,
|
||||
@@ -41,5 +77,17 @@ class RadarrClient(ApiClient):
|
||||
}
|
||||
return await self.post("/api/v3/movie", payload=payload)
|
||||
|
||||
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self.put("/api/v3/movie", payload=payload)
|
||||
|
||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||
|
||||
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/release/push", payload=payload)
|
||||
|
||||
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post(
|
||||
"/api/v3/command",
|
||||
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
|
||||
)
|
||||
|
||||
@@ -9,6 +9,23 @@ 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}")
|
||||
|
||||
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/rootfolder")
|
||||
|
||||
@@ -16,17 +33,62 @@ 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")
|
||||
|
||||
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,
|
||||
@@ -36,17 +98,32 @@ 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]]:
|
||||
return await self.put("/api/v3/series", payload=payload)
|
||||
|
||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||
|
||||
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/release/push", payload=payload)
|
||||
|
||||
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post(
|
||||
"/api/v3/command",
|
||||
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
|
||||
)
|
||||
|
||||
+267
-4
@@ -1,22 +1,90 @@
|
||||
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"))
|
||||
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")
|
||||
)
|
||||
@@ -35,9 +103,181 @@ 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")
|
||||
)
|
||||
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
|
||||
site_banner_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
|
||||
)
|
||||
site_banner_message: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
|
||||
)
|
||||
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")
|
||||
@@ -45,6 +285,11 @@ class Settings(BaseSettings):
|
||||
jellyseerr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
|
||||
)
|
||||
jellystat_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSTAT_URL", "JELLYSTAT_BASE_URL")
|
||||
)
|
||||
jellystat_api_key: Optional[str] = Field(default=None, validation_alias="JELLYSTAT_API_KEY")
|
||||
|
||||
jellyfin_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
|
||||
)
|
||||
@@ -70,6 +315,10 @@ class Settings(BaseSettings):
|
||||
sonarr_root_folder: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER")
|
||||
)
|
||||
sonarr_qbittorrent_category: Optional[str] = Field(
|
||||
default="sonarr",
|
||||
validation_alias=AliasChoices("SONARR_QBITTORRENT_CATEGORY"),
|
||||
)
|
||||
|
||||
radarr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL")
|
||||
@@ -83,6 +332,20 @@ class Settings(BaseSettings):
|
||||
radarr_root_folder: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
|
||||
)
|
||||
radarr_qbittorrent_category: Optional[str] = Field(
|
||||
default="radarr",
|
||||
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")
|
||||
@@ -102,7 +365,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"),
|
||||
)
|
||||
|
||||
|
||||
+3608
-105
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
|
||||
@@ -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)
|
||||
|
||||
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 | %(message)s",
|
||||
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)
|
||||
|
||||
+272
-11
@@ -1,28 +1,69 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
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 .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 .secret_storage import validate_secret_storage_configuration
|
||||
from .services.request_origins import is_allowed_request_origin
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
logger = logging.getLogger(__name__)
|
||||
_background_tasks: list[asyncio.Task[None]] = []
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
docs_url="/docs" if settings.api_docs_enabled else None,
|
||||
redoc_url=None,
|
||||
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -33,26 +74,246 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||
token = bind_request_id(request_id)
|
||||
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
|
||||
operation_token = None
|
||||
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
||||
operation_token = begin_operation(
|
||||
operation_id,
|
||||
label=request.headers.get("X-Magent-Operation-Label"),
|
||||
path=sanitize_path(request.url.path),
|
||||
)
|
||||
request.state.request_id = request_id
|
||||
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
||||
origin = str(request.headers.get("origin") or "")
|
||||
if origin and not is_allowed_request_origin(origin):
|
||||
record_api(request, 403, 0.0)
|
||||
if operation_id and operation_token is not None:
|
||||
finish_operation(operation_id, success=False, status_code=403)
|
||||
reset_operation(operation_token)
|
||||
reset_request_id(token)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "Cross-origin state change rejected"},
|
||||
headers={"X-Request-ID": request_id},
|
||||
)
|
||||
started_at = time.perf_counter()
|
||||
body_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 not admin_password or admin_password == "adminadmin":
|
||||
logger.warning(
|
||||
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
||||
)
|
||||
if bool(settings.api_docs_enabled):
|
||||
logger.warning(
|
||||
"security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
|
||||
)
|
||||
|
||||
|
||||
def _enforce_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"):
|
||||
raise RuntimeError(
|
||||
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
start_metrics()
|
||||
configure_logging(
|
||||
settings.log_level,
|
||||
settings.log_file,
|
||||
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()
|
||||
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,
|
||||
)
|
||||
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
|
||||
logger.info("Background imports and automation paused for initial setup")
|
||||
return
|
||||
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
|
||||
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
|
||||
_launch_background_task("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)
|
||||
logger.info("startup complete")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
+1842
-51
File diff suppressed because it is too large
Load Diff
+1474
-55
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -11,6 +12,14 @@ router = APIRouter(prefix="/branding", tags=["branding"])
|
||||
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
|
||||
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
|
||||
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
|
||||
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
|
||||
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
|
||||
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
|
||||
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
|
||||
_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:
|
||||
@@ -41,6 +50,18 @@ def _ensure_default_branding() -> None:
|
||||
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
|
||||
return
|
||||
_ensure_branding_dir()
|
||||
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
|
||||
try:
|
||||
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
|
||||
target.write(source.read())
|
||||
except OSError:
|
||||
pass
|
||||
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
|
||||
try:
|
||||
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
|
||||
target.write(source.read())
|
||||
except OSError:
|
||||
pass
|
||||
if not os.path.exists(_LOGO_PATH):
|
||||
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
@@ -65,35 +86,56 @@ def _ensure_default_branding() -> None:
|
||||
favicon.save(_FAVICON_PATH, format="ICO")
|
||||
|
||||
|
||||
def _resolve_branding_paths() -> tuple[str, str]:
|
||||
if _BRANDING_SOURCE == "data":
|
||||
_ensure_default_branding()
|
||||
return _LOGO_PATH, _FAVICON_PATH
|
||||
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
|
||||
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
|
||||
_ensure_default_branding()
|
||||
return _LOGO_PATH, _FAVICON_PATH
|
||||
|
||||
|
||||
@router.get("/logo.png")
|
||||
async def branding_logo() -> FileResponse:
|
||||
if not os.path.exists(_LOGO_PATH):
|
||||
_ensure_default_branding()
|
||||
if not os.path.exists(_LOGO_PATH):
|
||||
logo_path, _ = _resolve_branding_paths()
|
||||
if not os.path.exists(logo_path):
|
||||
raise HTTPException(status_code=404, detail="Logo not found")
|
||||
headers = {"Cache-Control": "public, max-age=300"}
|
||||
return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers)
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
return FileResponse(logo_path, media_type="image/png", headers=headers)
|
||||
|
||||
|
||||
@router.get("/favicon.ico")
|
||||
async def branding_favicon() -> FileResponse:
|
||||
if not os.path.exists(_FAVICON_PATH):
|
||||
_ensure_default_branding()
|
||||
if not os.path.exists(_FAVICON_PATH):
|
||||
_, favicon_path = _resolve_branding_paths()
|
||||
if not os.path.exists(favicon_path):
|
||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||
headers = {"Cache-Control": "public, max-age=300"}
|
||||
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
|
||||
|
||||
|
||||
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:
|
||||
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))
|
||||
except OSError as exc:
|
||||
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)
|
||||
@@ -1,7 +1,9 @@
|
||||
import os
|
||||
import re
|
||||
import mimetypes
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
import httpx
|
||||
|
||||
@@ -11,6 +13,7 @@ router = APIRouter(prefix="/images", tags=["images"])
|
||||
|
||||
_TMDB_BASE = "https://image.tmdb.org/t/p"
|
||||
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_filename(path: str) -> str:
|
||||
@@ -19,13 +22,24 @@ def _safe_filename(path: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
|
||||
return safe or "image"
|
||||
|
||||
|
||||
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
|
||||
def tmdb_cache_path(path: str, size: str) -> Optional[str]:
|
||||
if not path or "://" in path or ".." in path:
|
||||
return False
|
||||
return None
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
if size not in _ALLOWED_SIZES:
|
||||
return None
|
||||
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
||||
return os.path.join(cache_dir, _safe_filename(path))
|
||||
|
||||
|
||||
def is_tmdb_cached(path: str, size: str) -> bool:
|
||||
file_path = tmdb_cache_path(path, size)
|
||||
return bool(file_path and os.path.exists(file_path))
|
||||
|
||||
|
||||
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
|
||||
if not path or "://" in path or ".." in path:
|
||||
return False
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
@@ -33,9 +47,10 @@ async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
|
||||
if cache_mode != "cache":
|
||||
return False
|
||||
|
||||
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
file_path = os.path.join(cache_dir, _safe_filename(path))
|
||||
file_path = tmdb_cache_path(path, size)
|
||||
if not file_path:
|
||||
return False
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
if os.path.exists(file_path):
|
||||
return True
|
||||
|
||||
@@ -64,9 +79,10 @@ async def tmdb_image(path: str, size: str = "w342"):
|
||||
if cache_mode != "cache":
|
||||
return RedirectResponse(url=url)
|
||||
|
||||
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
file_path = os.path.join(cache_dir, _safe_filename(path))
|
||||
file_path = tmdb_cache_path(path, size)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=400, detail="Invalid image path")
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
headers = {"Cache-Control": "public, max-age=86400"}
|
||||
if os.path.exists(file_path):
|
||||
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
||||
@@ -77,6 +93,8 @@ async def tmdb_image(path: str, size: str = "w342"):
|
||||
if os.path.exists(file_path):
|
||||
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
||||
return FileResponse(file_path, media_type=media_type, headers=headers)
|
||||
raise HTTPException(status_code=502, detail="Image cache failed")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Image fetch failed: {exc}") from exc
|
||||
logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
|
||||
|
||||
return RedirectResponse(url=url)
|
||||
|
||||
@@ -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)
|
||||
+2714
-388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
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"])
|
||||
|
||||
_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 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"] = (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
|
||||
|
||||
|
||||
@router.get("/public")
|
||||
async def site_public() -> Dict[str, Any]:
|
||||
return _build_site_info(False)
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def site_info(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
return _build_site_info(True)
|
||||
+104
-11
@@ -1,17 +1,19 @@
|
||||
from typing import Any, Dict
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
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"
|
||||
@@ -93,3 +131,58 @@ async def services_status() -> Dict[str, Any]:
|
||||
overall = "degraded"
|
||||
|
||||
return {"overall": overall, "services": services}
|
||||
|
||||
|
||||
@router.post("/services/{service}/test")
|
||||
async def test_service(service: str) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
qbittorrent = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||
)
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
|
||||
service_key = service.strip().lower()
|
||||
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": (
|
||||
"Seerr",
|
||||
jellyseerr.configured(),
|
||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||
),
|
||||
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
||||
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
||||
"bazarr": (
|
||||
"Bazarr",
|
||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
||||
bazarr.get_system_status,
|
||||
),
|
||||
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
||||
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
||||
}
|
||||
|
||||
if service_key == "qbittorrent":
|
||||
return await _check_qbittorrent(qbittorrent)
|
||||
|
||||
if service_key not in checks:
|
||||
raise HTTPException(status_code=404, detail="Unknown service")
|
||||
|
||||
name, configured, func = checks[service_key]
|
||||
result = await _check(name, configured, func)
|
||||
if name == "Prowlarr" and result.get("status") == "up":
|
||||
health = result.get("detail")
|
||||
if isinstance(health, list) and health:
|
||||
result["status"] = "degraded"
|
||||
result["message"] = "Health warnings"
|
||||
return result
|
||||
|
||||
@@ -2,17 +2,49 @@ 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"}
|
||||
|
||||
|
||||
def get_runtime_settings():
|
||||
@@ -21,6 +53,8 @@ def get_runtime_settings():
|
||||
for key, value in overrides.items():
|
||||
if value is None:
|
||||
continue
|
||||
if key in _SKIP_OVERRIDE_FIELDS:
|
||||
continue
|
||||
if key in _INT_FIELDS:
|
||||
try:
|
||||
update[key] = int(value)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
+83
-7
@@ -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:
|
||||
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,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? Grizzlyflix issue #{item['id']}"
|
||||
body_text = (
|
||||
"Your repair looks ready to test.\n\n"
|
||||
f"{item.get('title') or 'Your reported issue'}\n\n"
|
||||
"Please try the affected content in Grizzlyflix. Is it fixed?\n\n"
|
||||
f"YES — it works: {issue_url}#yes\n"
|
||||
f"NO — still broken: {issue_url}#no\n\n"
|
||||
"Confirm your answer in Magent. You may need to sign in first.\n"
|
||||
"Yes closes the report. No keeps it open for another look.\n\n"
|
||||
f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically."
|
||||
)
|
||||
body_html = (
|
||||
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
|
||||
'<table role="presentation" style="max-width:560px;width:100%;margin:auto;background:#202023;border:1px solid #45454d;border-radius:18px;"><tr><td style="padding:28px;">'
|
||||
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">GRIZZLYFLIX · MAGENT</p>'
|
||||
'<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
|
||||
'<p style="font-size:17px;line-height:1.6;color:#e4e4e7;">Your repair looks ready to test. Give the affected content a try, then let us know:</p>'
|
||||
f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
|
||||
'<h2 style="font-size:26px;color:#fff;margin:24px 0 16px;">Is it fixed?</h2>'
|
||||
f'<a href="{escape(issue_url)}#yes" style="display:block;text-align:center;padding:20px;margin-bottom:12px;border-radius:12px;background:#b4f4d2;color:#10261b;text-decoration:none;font-size:24px;font-weight:bold;">YES — it works</a>'
|
||||
f'<a href="{escape(issue_url)}#no" style="display:block;text-align:center;padding:20px;border-radius:12px;background:#ffc1c5;color:#391318;text-decoration:none;font-size:24px;font-weight:bold;">NO — still broken</a>'
|
||||
'<p style="font-size:14px;line-height:1.6;color:#dedee3;">Confirm your answer in Magent. You may need to sign in first.<br>Yes closes the report. No keeps it open for another look.</p>'
|
||||
f'<p style="font-size:12px;line-height:1.6;color:#b9b9c3;">Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])}<br>If we do not hear back after the reminder period, this report will close automatically.</p>'
|
||||
'</td></tr></table></div>'
|
||||
)
|
||||
try:
|
||||
await send_generic_email(
|
||||
recipient_email=recipient,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
)
|
||||
sent = True
|
||||
except Exception as exc:
|
||||
delivery_error = str(exc)
|
||||
logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
|
||||
else:
|
||||
delivery_error = "No email address is stored for the reporter."
|
||||
|
||||
now = _now()
|
||||
state.update(
|
||||
{
|
||||
"status": "awaiting_confirmation",
|
||||
"attemptsSent": attempt_number,
|
||||
"maximumAttempts": maximum,
|
||||
"lastContactAt": now.isoformat(),
|
||||
"nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
|
||||
"intervalValue": interval_value,
|
||||
"intervalUnit": interval_unit,
|
||||
"lastDeliverySucceeded": sent,
|
||||
"lastDeliveryError": delivery_error,
|
||||
}
|
||||
)
|
||||
updated = update_portal_item(
|
||||
int(item["id"]),
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue confirmation schedule could not be saved")
|
||||
if sent:
|
||||
message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
|
||||
else:
|
||||
message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"confirmation_email_sent" if sent else "confirmation_email_failed",
|
||||
message,
|
||||
metadata={
|
||||
"attempt": attempt_number,
|
||||
"maximum": maximum,
|
||||
"nextContactAt": state["nextContactAt"],
|
||||
"deliveryError": delivery_error,
|
||||
},
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def begin_issue_confirmation(
|
||||
item_id: int,
|
||||
*,
|
||||
actor_username: str,
|
||||
actor_role: str,
|
||||
) -> Dict[str, Any]:
|
||||
item = get_portal_item(item_id)
|
||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
||||
raise ValueError("Issue not found")
|
||||
now = _now().isoformat()
|
||||
maximum, interval_value, interval_unit = _workflow_settings()
|
||||
state = {
|
||||
"status": "awaiting_confirmation",
|
||||
"startedAt": now,
|
||||
"attemptsSent": 0,
|
||||
"maximumAttempts": maximum,
|
||||
"lastContactAt": None,
|
||||
"nextContactAt": now,
|
||||
"intervalValue": interval_value,
|
||||
"intervalUnit": interval_unit,
|
||||
"confirmedAt": None,
|
||||
"closedAt": None,
|
||||
}
|
||||
updated = update_portal_item(
|
||||
item_id,
|
||||
status="awaiting_confirmation",
|
||||
issue_resolved_at=None,
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue confirmation workflow could not be started")
|
||||
_activity(
|
||||
item_id,
|
||||
"resolution_proposed",
|
||||
"The issue was marked fixed and sent to the reporter for confirmation.",
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
|
||||
)
|
||||
return await _contact_reporter(updated)
|
||||
|
||||
|
||||
def respond_to_issue_confirmation(
|
||||
item_id: int,
|
||||
*,
|
||||
resolved: bool,
|
||||
actor_username: str,
|
||||
actor_role: str,
|
||||
) -> Dict[str, Any]:
|
||||
item = get_portal_item(item_id)
|
||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
||||
raise ValueError("Issue not found")
|
||||
if str(item.get("status") or "").lower() != "awaiting_confirmation":
|
||||
raise ValueError("This issue is not waiting for resolution confirmation")
|
||||
if resolved:
|
||||
return _close_issue(
|
||||
item,
|
||||
reason="The reporter confirmed that the issue is fixed.",
|
||||
confirmed=True,
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
)
|
||||
|
||||
now = _now().isoformat()
|
||||
state = issue_resolution_state(item)
|
||||
state.update(
|
||||
{
|
||||
"status": "reported_still_broken",
|
||||
"reporterResponseAt": now,
|
||||
"nextContactAt": None,
|
||||
"closedAt": None,
|
||||
}
|
||||
)
|
||||
updated = update_portal_item(
|
||||
item_id,
|
||||
status="in_progress",
|
||||
issue_resolved_at=None,
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue could not be reopened")
|
||||
_activity(
|
||||
item_id,
|
||||
"resolution_rejected",
|
||||
"The reporter said the issue is still happening. The issue was returned to In progress.",
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def process_active_media_repairs() -> Dict[str, int]:
|
||||
items = list_portal_items(kind="issue", status="in_progress", limit=500)
|
||||
result = {"checked": 0, "waiting": 0, "completed": 0, "failed": 0}
|
||||
for item in items:
|
||||
tracking, activity = _repair_tracking(int(item["id"]))
|
||||
if not tracking:
|
||||
continue
|
||||
result["checked"] += 1
|
||||
try:
|
||||
evidence = await _media_repair_evidence(tracking)
|
||||
if evidence.get("complete"):
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"repair_verified",
|
||||
str(evidence.get("message") or "Magent verified the repaired media in Jellyfin."),
|
||||
metadata={
|
||||
"requestId": tracking.get("requestId"),
|
||||
"actionId": tracking.get("actionId"),
|
||||
},
|
||||
)
|
||||
await begin_issue_confirmation(
|
||||
int(item["id"]),
|
||||
actor_username=_SYSTEM_USER,
|
||||
actor_role="system",
|
||||
)
|
||||
result["completed"] += 1
|
||||
continue
|
||||
|
||||
result["waiting"] += 1
|
||||
if evidence.get("phase") == "indexing" and not any(
|
||||
str(entry.get("event_type") or "") == "repair_imported"
|
||||
for entry in activity
|
||||
):
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"repair_imported",
|
||||
str(evidence.get("message") or "The repaired file was imported and is waiting for Jellyfin."),
|
||||
metadata={
|
||||
"requestId": tracking.get("requestId"),
|
||||
"actionId": tracking.get("actionId"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
result["failed"] += 1
|
||||
logger.exception("automatic media repair check failed item_id=%s", item.get("id"))
|
||||
return result
|
||||
|
||||
|
||||
async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
|
||||
current = (now or _now()).astimezone(timezone.utc)
|
||||
items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
|
||||
result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
|
||||
maximum, _, _ = _workflow_settings()
|
||||
for item in items:
|
||||
state = issue_resolution_state(item)
|
||||
due_at = _parse_datetime(state.get("nextContactAt"))
|
||||
if due_at and due_at > current:
|
||||
continue
|
||||
try:
|
||||
attempts = max(0, int(state.get("attemptsSent") or 0))
|
||||
if maximum <= 0 or attempts >= maximum:
|
||||
_close_issue(
|
||||
item,
|
||||
reason=(
|
||||
"Issue closed automatically because reporter confirmation emails are disabled."
|
||||
if maximum <= 0
|
||||
else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
|
||||
),
|
||||
confirmed=False,
|
||||
)
|
||||
result["closed"] += 1
|
||||
else:
|
||||
await _contact_reporter(item)
|
||||
result["contacted"] += 1
|
||||
except Exception:
|
||||
result["failed"] += 1
|
||||
logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
|
||||
return result
|
||||
|
||||
|
||||
async def run_issue_confirmation_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
repair_result = await process_active_media_repairs()
|
||||
if repair_result["completed"] or repair_result["failed"]:
|
||||
logger.info("automatic media repair sweep complete result=%s", repair_result)
|
||||
result = await process_due_issue_confirmations()
|
||||
if result["contacted"] or result["closed"] or result["failed"]:
|
||||
logger.info("issue confirmation sweep complete result=%s", result)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("issue confirmation sweep failed")
|
||||
await asyncio.sleep(60)
|
||||
@@ -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,10 +1,26 @@
|
||||
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
|
||||
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 (
|
||||
extract_jellyseerr_user_email,
|
||||
get_cached_jellyseerr_users,
|
||||
save_jellyfin_users_cache,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,15 +33,56 @@ async def sync_jellyfin_users() -> int:
|
||||
users = await client.get_users()
|
||||
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()
|
||||
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
|
||||
if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"):
|
||||
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 Grizzlyflix."
|
||||
return {'subject': 'Confirm your Grizzlyflix 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 ON GRIZZLYFLIX',
|
||||
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 Grizzlyflix ↗</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 Grizzlyflix.</p>')
|
||||
period = f"{content['period_start'][:10]} to {content['period_end'][:10]} · UTC"
|
||||
footer = f'You subscribed to the Grizzlyflix 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 Grizzlyflix: {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 on Grizzlyflix',
|
||||
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 Grizzlyflix', 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 on Grizzlyflix · {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 on Grizzlyflix · {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,29 @@
|
||||
"""Configured public email links, independent of request Host/forwarded headers."""
|
||||
from urllib.parse import urlsplit
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
|
||||
def valid_public_url(value):
|
||||
value = str(value or '').strip().rstrip('/')
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
if (parsed.scheme in {'http', 'https'} and parsed.hostname
|
||||
and not (parsed.username or parsed.password or parsed.query or parsed.fragment)
|
||||
and (parsed.port is None or parsed.port > 0)
|
||||
and not any(c.isspace() or ord(c) < 33 or c in '<>"\\' for c in value)):
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
def magent_public_url(legacy_url=''):
|
||||
runtime = get_runtime_settings()
|
||||
proxy = getattr(runtime, 'magent_proxy_base_url', None)
|
||||
application = getattr(runtime, 'magent_application_url', None)
|
||||
if getattr(runtime, 'magent_proxy_enabled', False) and str(proxy or '').strip():
|
||||
return valid_public_url(proxy)
|
||||
if str(application or '').strip():
|
||||
return valid_public_url(application)
|
||||
# Preserve pre-existing installations until Hosting & proxy has been configured.
|
||||
return valid_public_url(legacy_url)
|
||||
@@ -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,41 @@
|
||||
"""State-changing requests may originate only from explicitly configured sites.
|
||||
|
||||
The public Hosting & proxy URL can be stored in the database, while the CORS
|
||||
environment setting still has its localhost default on an upgraded install.
|
||||
Never infer a trusted origin from request Host or forwarded headers.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from ..config import settings
|
||||
from .public_urls import magent_public_url, valid_public_url
|
||||
|
||||
|
||||
def _origin(value: str, *, configured_url: bool = False) -> tuple[str, str, int] | None:
|
||||
value = str(value or "")
|
||||
if any(character.isspace() or ord(character) < 33 or ord(character) == 127 for character in value):
|
||||
return None
|
||||
if "?" in value or "#" in value:
|
||||
return None
|
||||
validated = valid_public_url(value)
|
||||
if not validated:
|
||||
return None
|
||||
parsed = urlsplit(value)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
return None
|
||||
if not configured_url and parsed.path:
|
||||
return None
|
||||
return (
|
||||
parsed.scheme.lower(),
|
||||
parsed.hostname.lower(),
|
||||
parsed.port or (443 if parsed.scheme == "https" else 80),
|
||||
)
|
||||
|
||||
|
||||
def is_allowed_request_origin(origin: str) -> bool:
|
||||
candidate = _origin(origin)
|
||||
if candidate is None:
|
||||
return False
|
||||
if candidate == _origin(str(settings.cors_allow_origin or "").rstrip("/")):
|
||||
return True
|
||||
return candidate == _origin(magent_public_url(), configured_url=True)
|
||||
+1186
-79
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..db import get_setting, set_setting, delete_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
|
||||
JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
|
||||
JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
|
||||
JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
|
||||
parsed = _parse_iso(cached_at)
|
||||
if not parsed:
|
||||
return False
|
||||
age = datetime.now(timezone.utc) - parsed
|
||||
return age <= timedelta(minutes=max_age_minutes)
|
||||
|
||||
|
||||
def _load_cached_users(
|
||||
cache_key: str, cache_at_key: str, max_age_minutes: int
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
cached_at = get_setting(cache_at_key)
|
||||
if not _cache_is_fresh(cached_at, max_age_minutes):
|
||||
return None
|
||||
raw = get_setting(cache_key)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
if isinstance(data, list):
|
||||
return [item for item in data if isinstance(item, dict)]
|
||||
return None
|
||||
|
||||
|
||||
def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
|
||||
payload = json.dumps(users, ensure_ascii=True)
|
||||
set_setting(cache_key, payload)
|
||||
set_setting(cache_at_key, _now_iso())
|
||||
|
||||
|
||||
def _normalized_handles(value: Any) -> List[str]:
|
||||
if not isinstance(value, str):
|
||||
return []
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
return []
|
||||
handles = [normalized]
|
||||
if "@" in normalized:
|
||||
handles.append(normalized.split("@", 1)[0])
|
||||
return list(dict.fromkeys(handles))
|
||||
|
||||
|
||||
def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
candidate_to_id: Dict[str, int] = {}
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
user_id = user.get("id") or user.get("userId") or user.get("Id")
|
||||
try:
|
||||
user_id = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for key in ("username", "email", "displayName", "name"):
|
||||
for handle in _normalized_handles(user.get(key)):
|
||||
candidate_to_id.setdefault(handle, user_id)
|
||||
return candidate_to_id
|
||||
|
||||
|
||||
def find_matching_jellyseerr_user(
|
||||
identifier: str, users: List[Dict[str, Any]]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
target_handles = set(_normalized_handles(identifier))
|
||||
if not target_handles:
|
||||
return None
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
for key in ("username", "email", "displayName", "name"):
|
||||
if target_handles.intersection(_normalized_handles(user.get(key))):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if not isinstance(user, dict):
|
||||
return None
|
||||
value = user.get("email")
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
candidate = value.strip()
|
||||
if not candidate or "@" not in candidate:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def match_jellyseerr_user_id(
|
||||
username: str, candidate_map: Dict[str, int]
|
||||
) -> Optional[int]:
|
||||
for handle in _normalized_handles(username):
|
||||
matched = candidate_map.get(handle)
|
||||
if matched is not None:
|
||||
return matched
|
||||
return None
|
||||
|
||||
|
||||
def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": user.get("id") or user.get("userId") or user.get("Id"),
|
||||
"email": user.get("email"),
|
||||
"username": user.get("username"),
|
||||
"displayName": user.get("displayName"),
|
||||
"name": user.get("name"),
|
||||
}
|
||||
)
|
||||
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
|
||||
logger.debug("Cached Seerr users: %s", len(normalized))
|
||||
return normalized
|
||||
|
||||
|
||||
def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
||||
return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
|
||||
|
||||
|
||||
def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": user.get("Id"),
|
||||
"name": user.get("Name"),
|
||||
"hasPassword": user.get("HasPassword"),
|
||||
"lastLoginDate": user.get("LastLoginDate"),
|
||||
}
|
||||
)
|
||||
_save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
|
||||
logger.debug("Cached Jellyfin users: %s", len(normalized))
|
||||
return normalized
|
||||
|
||||
|
||||
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
||||
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
|
||||
|
||||
|
||||
def clear_user_import_caches() -> Dict[str, int]:
|
||||
cleared = 0
|
||||
for key in (
|
||||
JELLYSEERR_CACHE_KEY,
|
||||
JELLYSEERR_CACHE_AT_KEY,
|
||||
JELLYFIN_CACHE_KEY,
|
||||
JELLYFIN_CACHE_AT_KEY,
|
||||
):
|
||||
delete_setting(key)
|
||||
cleared += 1
|
||||
logger.debug("Cleared user import cache keys: %s", cleared)
|
||||
return {"settingsKeysCleared": cleared}
|
||||
@@ -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,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,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)
|
||||
@@ -0,0 +1,146 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from backend.app.config import settings
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.app import db
|
||||
from backend.app.feature_access import FEATURES, permissions, update_permissions
|
||||
from backend.app.routers import admin, auth, events, insights, portal, recaps, requests
|
||||
from backend.app.security import create_access_token
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
|
||||
secret.start()
|
||||
self.addCleanup(secret.stop)
|
||||
access = patch.object(
|
||||
requests,
|
||||
"_ensure_request_mutation_access",
|
||||
new=AsyncMock(return_value=None),
|
||||
)
|
||||
access.start()
|
||||
self.addCleanup(access.stop)
|
||||
db.create_user('feature-viewer', 'Example-password123!', role='user')
|
||||
db.create_user('feature-admin', 'Example-password123!', role='admin')
|
||||
self.user = db.get_user_by_username('feature-viewer')
|
||||
app = FastAPI()
|
||||
for module in (admin, auth, events, insights, portal, recaps, requests):
|
||||
app.include_router(module.router)
|
||||
self.client = TestClient(app)
|
||||
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
|
||||
|
||||
def test_defaults_persist_and_invites_share_existing_setting(self):
|
||||
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False, ignore_profile_limits=False))
|
||||
update_permissions({'stats': False, 'invites': True}, self.user['username'])
|
||||
db.init_db()
|
||||
fresh = db.get_user_by_username(self.user['username'])
|
||||
self.assertTrue(fresh['invite_management_enabled'])
|
||||
self.assertFalse(permissions(fresh)['stats'])
|
||||
db.set_user_invite_management_enabled(self.user['username'], False)
|
||||
self.assertFalse(permissions(db.get_user_by_username(self.user['username']))['invites'])
|
||||
|
||||
def test_all_feature_apis_reject_disabled_access_with_existing_token(self):
|
||||
update_permissions(dict.fromkeys(FEATURES, False), self.user['username'])
|
||||
endpoints = [
|
||||
('GET', '/insights', None), ('GET', '/insights/reports/monthly', None),
|
||||
('GET', '/insights/reports/monthly.csv', None), ('GET', '/insights/artwork/item?token=x', None),
|
||||
('GET', '/profile/email-recaps', None), ('POST', '/profile/email-recaps/send', {}),
|
||||
('GET', '/requests/recent', None), ('GET', '/requests/search?query=Movie', None),
|
||||
('GET', '/requests/request-options?mediaType=movie&tmdbId=1', None),
|
||||
('POST', '/requests/create', {'mediaType': 'movie', 'tmdbId': 1}),
|
||||
('GET', '/requests/1/snapshot', None), ('POST', '/requests/1/actions/search', {}),
|
||||
('GET', '/requests/1/issue-options', None), ('POST', '/requests/1/actions/replace', {}),
|
||||
('GET', '/portal/items?kind=issue', None), ('GET', '/portal/requests', None),
|
||||
('POST', '/portal/items', {'kind': 'issue'}), ('POST', '/portal/items', {'kind': 'request'}),
|
||||
('GET', '/portal/issues/media-status', None), ('POST', '/portal/requests/1/issues', {}),
|
||||
('GET', '/auth/profile/invites', None), ('POST', '/auth/profile/invites', {}),
|
||||
('PUT', '/auth/profile/invites/1', {}), ('DELETE', '/auth/profile/invites/1', None),
|
||||
('GET', '/events/stream', None), ('GET', '/events/requests/1/stream', None),
|
||||
]
|
||||
for method, path, payload in endpoints:
|
||||
with self.subTest(path=path, method=method):
|
||||
self.assertEqual(self.client.request(method, path, json=payload).status_code, 403)
|
||||
self.assertEqual(self.client.get('/auth/me').json()['features'], dict.fromkeys(FEATURES, False))
|
||||
self.assertEqual(self.client.get('/auth/profile').status_code, 200)
|
||||
|
||||
def test_bulk_is_admin_only_strict_and_leaves_other_features_untouched(self):
|
||||
self.assertEqual(self.client.put('/admin/users/features/bulk', json={'issues': False}).status_code, 403)
|
||||
self.client.headers['Authorization'] = 'Bearer ' + create_access_token('feature-admin', 'admin')
|
||||
for invalid in ({'issues': 'false'}, {'unknown': True}, {}):
|
||||
self.assertEqual(self.client.put('/admin/users/features/bulk', json=invalid).status_code, 400)
|
||||
response = self.client.put('/admin/users/features/bulk', json={'issues': False})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()['updated'], 1)
|
||||
self.assertFalse(permissions(self.user)['issues'])
|
||||
self.assertTrue(permissions(self.user)['requests'])
|
||||
self.assertTrue(all(permissions(db.get_user_by_username('feature-admin')).values()))
|
||||
self.assertEqual(self.client.put('/admin/users/feature-admin/features', json={'stats': False}).status_code, 400)
|
||||
self.assertEqual(self.client.put('/admin/users/missing/features', json={'stats': False}).status_code, 404)
|
||||
|
||||
def test_issue_and_request_item_routes_cannot_bypass_disabled_feature(self):
|
||||
issue = db.create_portal_item(kind='issue', title='Problem', description='Problem', created_by_username=self.user['username'], created_by_id=self.user['id'])
|
||||
update_permissions({'issues': False}, self.user['username'])
|
||||
for path in (f'/portal/items/{issue["id"]}', f'/portal/items/{issue["id"]}/comments', '/portal/items', '/portal/overview'):
|
||||
self.assertEqual(self.client.get(path).status_code, 403)
|
||||
self.assertEqual(self.client.get('/portal/requests').status_code, 200)
|
||||
self.assertEqual(self.client.get('/portal/items?kind=request').status_code, 200)
|
||||
update_permissions({'issues': True, 'requests': False, 'new_requests': False}, self.user['username'])
|
||||
self.assertEqual(self.client.get(f'/portal/items/{issue["id"]}').status_code, 200)
|
||||
self.assertEqual(self.client.get('/portal/items?kind=issue').status_code, 200)
|
||||
overview = self.client.get('/portal/overview?kind=issue')
|
||||
self.assertEqual(overview.status_code, 200)
|
||||
self.assertEqual(overview.json()['overview']['by_kind'], {'issue': 1})
|
||||
self.assertEqual(self.client.post('/requests/create', json={'mediaType': 'movie', 'tmdbId': 1}).status_code, 403)
|
||||
|
||||
def test_deleted_account_does_not_leave_permissions_for_reused_id(self):
|
||||
update_permissions({'stats': False}, self.user['username'])
|
||||
db.delete_user_by_username(self.user['username'])
|
||||
with db._connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_feature_permissions').fetchone()[0], 0)
|
||||
|
||||
|
||||
def test_open_request_stream_closes_after_permission_revocation(self):
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
from types import SimpleNamespace
|
||||
async def scenario():
|
||||
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
|
||||
response = await events.events_stream(request, user={**self.user, "features": permissions(self.user)})
|
||||
iterator = response.body_iterator
|
||||
self.assertIn('retry', await anext(iterator))
|
||||
update_permissions({'requests': False}, self.user['username'])
|
||||
with self.assertRaises(StopAsyncIteration):
|
||||
await anext(iterator)
|
||||
asyncio.run(scenario())
|
||||
|
||||
def test_legacy_portal_kind_normalization_cannot_bypass_permissions(self):
|
||||
update_permissions({'requests': False, 'new_requests': False, 'issues': True}, self.user['username'])
|
||||
for kind in ['request', 'REQUEST', ' Request ', ' ', '']:
|
||||
with self.subTest(kind=kind):
|
||||
self.assertEqual(self.client.get('/portal/items', params={'kind': kind}).status_code, 403)
|
||||
self.assertEqual(self.client.get('/portal/overview', params={'kind': kind}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
|
||||
|
||||
def test_manual_override_permission_is_checked_again_at_download(self):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from backend.app.models import Snapshot, RequestType
|
||||
from backend.app.services import manual_releases
|
||||
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||
snapshot=Snapshot(request_id='42',title='Example',request_type=RequestType.tv,raw={'arr':{'item':{'id':55}}})
|
||||
release={'guid':'out','indexerId':1,'title':'Example','requiresOverride':True,'rejections':['Quality is not wanted in profile']}
|
||||
payload={**release,'ignoreProfileLimits':True,'selectionToken':manual_releases.issue_selection(release,'42',self.user,'http://sonarr',55)}
|
||||
collector=SimpleNamespace(configured=lambda:True,grab_release=AsyncMock(return_value={}))
|
||||
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,'SonarrClient',return_value=collector),patch.object(requests,'save_action'):
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,403)
|
||||
collector.grab_release.assert_not_awaited()
|
||||
update_permissions({'ignore_profile_limits':True},self.user['username'])
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,200)
|
||||
update_permissions({'ignore_profile_limits':False},self.user['username'])
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json={**payload,'requiresOverride':False,'approved':True}).status_code,403)
|
||||
collector.grab_release.assert_awaited_once()
|
||||
@@ -0,0 +1,472 @@
|
||||
import json
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
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.clients.jellystat import JellystatClient
|
||||
from backend.app.routers import identities
|
||||
from backend.app.services import identity_review as review
|
||||
from backend.app.services.jellyfin_identity import link_user, linked_user_id
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
JF = "a" * 32
|
||||
OTHER = "b" * 32
|
||||
SERVER = "c" * 32
|
||||
ADMIN = {"username": "admin", "role": "admin"}
|
||||
|
||||
|
||||
class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
db.create_user("Georgia", "jellyfin-user", auth_provider="jellyfin")
|
||||
self.user_id = db.get_user_by_username("Georgia")["id"]
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="SECRET-JF",
|
||||
jellyseerr_base_url="http://seerr", jellyseerr_api_key="SECRET-SEERR",
|
||||
jellystat_base_url="http://jellystat", jellystat_api_key="SECRET-STATS")
|
||||
runtime_patch = patch.object(review, "get_runtime_settings", return_value=self.runtime)
|
||||
runtime_patch.start()
|
||||
self.addCleanup(runtime_patch.stop)
|
||||
self.jf = {"state": "available", "server_id": SERVER, "users": [{"id": JF, "name": "Georgia"}]}
|
||||
self.seerr = {"state": "available", "users": [{"id": 20, "name": "An unrelated display name", "jellyfin_id": JF}]}
|
||||
self.js = {JF: {"state": "matched", "id": JF, "name": "Georgia"}}
|
||||
|
||||
def build(self):
|
||||
local = review.read_snapshot()
|
||||
return review.build_report(local, self.jf, self.seerr, self.js, self.runtime), local
|
||||
|
||||
def row(self, report):
|
||||
return next(row for row in report["rows"] if row["user"]["id"] == self.user_id)
|
||||
|
||||
async def test_manual_selection_resolves_different_username_without_guessing(self):
|
||||
self.jf['users'][0]['name'] = 'Different Jellyfin name'
|
||||
before = review.read_snapshot()
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)['state'], 'unlinked')
|
||||
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
|
||||
self.assertTrue(self.row(report)['can_confirm'])
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
|
||||
self.assertEqual(self.row(self.build()[0])['state'], 'confirmed')
|
||||
|
||||
async def test_manual_selection_cannot_replace_stored_or_confirmed_identity(self):
|
||||
self.jf['users'].append({'id': OTHER, 'name': 'Other'})
|
||||
self.seerr['users'].append({'id': 21, 'name': 'Other', 'jellyfin_id': OTHER})
|
||||
self.js[OTHER] = {'state': 'matched', 'id': OTHER}
|
||||
report, local = self.build()
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
local = review.read_snapshot()
|
||||
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(review.read_snapshot(), local)
|
||||
|
||||
async def test_manual_selection_checks_missing_ids_and_duplicate_owners(self):
|
||||
self.jf['users'][0]['name'] = 'Different'
|
||||
for state in ['missing', 'unavailable', 'not_configured']:
|
||||
self.js[JF] = {'state': state}
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
self.js[JF] = {'state': 'matched', 'id': JF}
|
||||
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
|
||||
self.assertEqual(self.row(report)['state'], 'conflict')
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {999: JF})
|
||||
self.assertEqual(error.exception.status_code, 404)
|
||||
|
||||
async def test_resolution_rechecks_live_services_and_rejects_changed_selection(self):
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js):
|
||||
before = review.read_snapshot()
|
||||
preview = await review.resolve_identity(self.user_id, JF)
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
await review.resolve_identity(self.user_id, OTHER, preview['revision'], ADMIN)
|
||||
self.assertEqual(error.exception.status_code, 409)
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
result = await review.resolve_identity(self.user_id, JF, preview['revision'], ADMIN)
|
||||
self.assertEqual(result['confirmed'], 1)
|
||||
|
||||
async def test_repair_replaces_wrong_local_link_and_records_before_after(self):
|
||||
link_user('Georgia', OTHER, self.runtime.jellyfin_base_url)
|
||||
db.set_user_jellyseerr_id('Georgia', 999)
|
||||
before = review.read_snapshot()
|
||||
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
|
||||
self.assertTrue(self.row(report)['can_confirm'])
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN, repair=True)
|
||||
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
|
||||
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 20)
|
||||
with closing(db._connect()) as conn:
|
||||
audit = conn.execute('SELECT before_json,after_json,repaired_by FROM user_identity_repairs').fetchone()
|
||||
self.assertEqual(json.loads(audit[0])['seerr_user_id'], 999)
|
||||
self.assertEqual(json.loads(audit[1])['jellyfin_user_id'], JF)
|
||||
self.assertEqual(audit[2], 'admin')
|
||||
|
||||
async def test_repair_preserves_duplicate_ownership_and_server_guards(self):
|
||||
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
|
||||
local = review.read_snapshot()
|
||||
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute('DELETE FROM users WHERE username=?', ('Owner',))
|
||||
report, local = self.build()
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.jf['server_id'] = OTHER
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
|
||||
async def test_repair_does_not_invent_missing_seerr_identity(self):
|
||||
self.seerr['users'][0]['jellyfin_id'] = OTHER
|
||||
local = review.read_snapshot()
|
||||
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
|
||||
self.assertEqual(self.row(report)['state'], 'unlinked')
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
|
||||
self.assertEqual(local, review.read_snapshot())
|
||||
|
||||
async def test_repair_audit_failure_rolls_back_links(self):
|
||||
link_user('Georgia', OTHER, self.runtime.jellyfin_base_url)
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute("CREATE TRIGGER fail_identity_audit BEFORE INSERT ON user_identity_repairs BEGIN SELECT RAISE(ABORT, 'fixture'); END")
|
||||
local = review.read_snapshot()
|
||||
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
|
||||
self.assertEqual(local, review.read_snapshot())
|
||||
|
||||
async def test_repair_rechecks_revision_and_updates_confirmed_ids(self):
|
||||
report, local = self.build()
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.jf['users'][0]['id'] = OTHER
|
||||
self.seerr['users'][0]['jellyfin_id'] = OTHER
|
||||
self.js = {OTHER: {'state': 'matched', 'id': OTHER}}
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js):
|
||||
preview = await review.repair_identity(self.user_id, OTHER)
|
||||
with self.assertRaises(HTTPException):
|
||||
await review.repair_identity(self.user_id, OTHER, 'f' * 64, ADMIN)
|
||||
await review.repair_identity(self.user_id, OTHER, preview['revision'], ADMIN)
|
||||
self.assertEqual(review.read_snapshot()['confirmations'][0]['jellyfin_user_id'], OTHER)
|
||||
|
||||
async def test_single_account_import_is_explicit_and_rechecked_before_local_save(self):
|
||||
self.seerr['users'] = []
|
||||
async def imported(*args, **kwargs):
|
||||
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}]
|
||||
return []
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
|
||||
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported) as post:
|
||||
before = review.read_snapshot()
|
||||
blocked = await review.repair_identity(self.user_id, JF)
|
||||
self.assertFalse(blocked['row']['can_confirm'])
|
||||
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
|
||||
self.assertEqual(preview['action'], 'import_seerr')
|
||||
self.assertTrue(preview['row']['can_confirm'])
|
||||
post.assert_not_called()
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
with self.assertRaises(HTTPException):
|
||||
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, False)
|
||||
post.assert_not_called()
|
||||
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
|
||||
post.assert_awaited_once_with('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [JF]})
|
||||
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 25)
|
||||
|
||||
async def test_failed_import_never_writes_local_links_or_retries(self):
|
||||
self.seerr['users'] = []
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
|
||||
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=httpx.ReadTimeout('fixture')) as post:
|
||||
before = review.read_snapshot()
|
||||
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
|
||||
self.assertEqual(error.exception.status_code, 502)
|
||||
self.assertEqual(post.await_count, 1)
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
|
||||
async def test_import_preserves_upstream_account_when_local_save_is_blocked(self):
|
||||
self.seerr['users'] = []
|
||||
async def imported(*args, **kwargs):
|
||||
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}]
|
||||
db.set_user_jellyseerr_id('Georgia', 999)
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
|
||||
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported), \
|
||||
patch.object(review.JellyseerrClient, 'delete_user', new_callable=AsyncMock) as delete:
|
||||
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
|
||||
self.assertIn('Seerr import completed', error.exception.detail)
|
||||
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 999)
|
||||
self.assertEqual(review.read_snapshot()['confirmations'], [])
|
||||
delete.assert_not_called()
|
||||
|
||||
async def test_import_blocks_existing_name_with_different_jellyfin_id(self):
|
||||
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': OTHER}]
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
|
||||
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock) as post:
|
||||
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
|
||||
self.assertFalse(preview['row']['can_confirm'])
|
||||
with self.assertRaises(HTTPException):
|
||||
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
|
||||
post.assert_not_called()
|
||||
|
||||
async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self):
|
||||
before = review.read_snapshot()
|
||||
report, _ = self.build()
|
||||
row = self.row(report)
|
||||
self.assertEqual(row["basis"], "suggested_username")
|
||||
self.assertEqual(row["state"], "ready")
|
||||
self.assertEqual(row["seerr"][0]["id"], 20)
|
||||
self.assertEqual(before, review.read_snapshot())
|
||||
serialized = json.dumps(report)
|
||||
for private in ["SECRET", "password_hash", "jellyfin_api_key", "email"]:
|
||||
self.assertNotIn(private, serialized)
|
||||
|
||||
async def test_confirmation_persists_both_links_and_survives_legacy_sync(self):
|
||||
report, local = self.build()
|
||||
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(result["confirmed"], 1)
|
||||
self.assertEqual(linked_user_id("Georgia", self.runtime.jellyfin_base_url), JF)
|
||||
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
|
||||
saved = review.read_snapshot()["confirmations"][0]
|
||||
self.assertEqual(saved["jellyfin_server_id"], SERVER)
|
||||
self.assertEqual(saved["confirmed_by"], "admin")
|
||||
db.set_user_jellyseerr_id("Georgia", 999)
|
||||
link_user("Georgia", OTHER, "http://other-server")
|
||||
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
|
||||
self.assertIsNone(linked_user_id("Georgia", "http://other-server"))
|
||||
refreshed, _ = self.build()
|
||||
self.assertEqual(self.row(refreshed)["state"], "confirmed")
|
||||
self.assertFalse(self.row(refreshed)["can_confirm"])
|
||||
|
||||
async def test_hidden_duplicate_seerr_and_jellyfin_rows_block_confirmation(self):
|
||||
db.set_user_jellyseerr_id("Georgia", 20)
|
||||
db.create_user("georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr", jellyseerr_user_id=20)
|
||||
report, local = self.build()
|
||||
self.assertEqual(len(db.get_all_users()), 1)
|
||||
self.assertEqual(len(report["rows"]), 2)
|
||||
self.assertTrue(all(row["state"] == "conflict" for row in report["rows"]))
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(review.read_snapshot()["confirmations"], [])
|
||||
|
||||
async def test_whitespace_accounts_and_wrong_seerr_mapping_are_conflicts(self):
|
||||
self.jf["users"].append({"id": OTHER, "name": "Georgia "})
|
||||
self.seerr["users"].append({"id": 21, "name": "Georgia ", "jellyfin_id": OTHER})
|
||||
db.set_user_jellyseerr_id("Georgia", 21)
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "conflict")
|
||||
self.assertFalse(self.row(report)["can_confirm"])
|
||||
|
||||
async def test_case_duplicates_in_magent_remain_visible_and_blocked(self):
|
||||
# Legacy duplicate predates the normalized-name creation guard.
|
||||
with db._connect() as conn:
|
||||
conn.execute("INSERT INTO users(username,password_hash,role,auth_provider,created_at) VALUES('georgia','unused','user','jellyfin','2026-01-01')")
|
||||
report, _ = self.build()
|
||||
self.assertEqual(report["counts"]["conflict"], 2)
|
||||
self.assertEqual(report["counts"]["ready"], 0)
|
||||
|
||||
async def test_email_prefix_and_local_username_do_not_claim_an_identity(self):
|
||||
db.create_user("Georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr")
|
||||
self.jf["users"].append({"id": OTHER, "name": "local"})
|
||||
db.create_user("local", "password", auth_provider="local")
|
||||
report, _ = self.build()
|
||||
for row in report["rows"]:
|
||||
if row["user"]["id"] != self.user_id:
|
||||
self.assertIsNone(row["candidate_jellyfin_id"])
|
||||
self.assertFalse(row["can_confirm"])
|
||||
|
||||
async def test_missing_or_unavailable_services_never_confirm(self):
|
||||
for state in ["missing", "unavailable", "not_configured"]:
|
||||
self.js[JF] = {"state": state}
|
||||
report, _ = self.build()
|
||||
self.assertFalse(self.row(report)["can_confirm"])
|
||||
self.js = {JF: {"state": "matched", "id": JF}}
|
||||
self.seerr = {"state": "unavailable", "users": []}
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "unavailable")
|
||||
|
||||
async def test_duplicate_upstream_id_and_orphaned_reservations_are_blocked(self):
|
||||
self.seerr["users"].append({"id": 21, "name": "Other", "jellyfin_id": JF})
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "conflict")
|
||||
self.seerr["users"].pop()
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute("INSERT INTO jellyfin_user_links VALUES (?,?,?)", (review.source_key(self.runtime.jellyfin_base_url), 9999, JF))
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "conflict")
|
||||
|
||||
async def test_wrong_stored_id_is_not_silently_replaced(self):
|
||||
link_user("Georgia", OTHER, self.runtime.jellyfin_base_url)
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "conflict")
|
||||
self.assertEqual(self.row(report)["candidate_jellyfin_id"], OTHER)
|
||||
|
||||
async def test_account_changes_reject_whole_save(self):
|
||||
report, local = self.build()
|
||||
db.set_user_jellyseerr_id("Georgia", 99)
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
self.assertEqual(review.read_snapshot()["confirmations"], [])
|
||||
self.assertIsNone(linked_user_id("Georgia", self.runtime.jellyfin_base_url))
|
||||
|
||||
async def test_settings_changes_reject_save(self):
|
||||
report, local = self.build()
|
||||
db.set_setting("jellyfin_base_url", "http://changed")
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
|
||||
async def test_save_reads_real_runtime_settings_inside_transaction(self):
|
||||
from backend.app.runtime import get_runtime_settings
|
||||
for key in review.CONFIG_KEYS:
|
||||
db.set_setting(key, getattr(self.runtime, key))
|
||||
report, local = self.build()
|
||||
with patch.object(review, "get_runtime_settings", get_runtime_settings):
|
||||
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(result["confirmed"], 1)
|
||||
|
||||
async def test_batch_rolls_back_all_links_if_a_later_write_fails(self):
|
||||
db.create_user("Second", "jellyfin-user", auth_provider="jellyfin")
|
||||
second_id = db.get_user_by_username("Second")["id"]
|
||||
self.jf["users"].append({"id": OTHER, "name": "Second"})
|
||||
self.seerr["users"].append({"id": 21, "name": "Second", "jellyfin_id": OTHER})
|
||||
self.js[OTHER] = {"state": "matched", "id": OTHER}
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute(f"""CREATE TRIGGER fail_second_confirmation BEFORE INSERT ON user_identity_confirmations
|
||||
WHEN NEW.local_user_id={second_id} BEGIN SELECT RAISE(ABORT, 'fixture conflict'); END""")
|
||||
report, local = self.build()
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id, second_id], ADMIN)
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
after = review.read_snapshot()
|
||||
self.assertEqual(after["confirmations"], [])
|
||||
self.assertEqual(after["links"], [])
|
||||
self.assertTrue(all(row["jellyseerr_user_id"] is None for row in after["users"]))
|
||||
|
||||
async def test_confirmation_rechecks_live_report_and_rejects_stale_revision(self):
|
||||
report, local = self.build()
|
||||
changed = {**report, "revision": "f" * 64}
|
||||
with patch.object(review, "review_identities", new_callable=AsyncMock, return_value=(changed, local, self.runtime)), \
|
||||
patch.object(review, "save_confirmations") as save:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
await review.confirm_identities(report["revision"], [self.user_id], ADMIN)
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
save.assert_not_called()
|
||||
|
||||
async def test_different_server_cannot_reuse_confirmed_id(self):
|
||||
report, local = self.build()
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.jf["server_id"] = OTHER
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "conflict")
|
||||
|
||||
async def test_report_revision_ignores_time_but_detects_mapping_changes(self):
|
||||
a, _ = self.build()
|
||||
b, _ = self.build()
|
||||
self.assertEqual(a["revision"], b["revision"])
|
||||
self.seerr["users"][0]["id"] = 99
|
||||
c, _ = self.build()
|
||||
self.assertNotEqual(a["revision"], c["revision"])
|
||||
|
||||
async def test_seerr_directory_requires_complete_unique_pages(self):
|
||||
users = [{"id": i, "jellyfinUserId": f"{i:032x}", "displayName": f"User {i}"} for i in range(1, 102)]
|
||||
pages = [{"pageInfo": {"results": 101}, "results": users[:100]}, {"pageInfo": {"results": 101}, "results": users[100:]}]
|
||||
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, side_effect=pages) as get:
|
||||
result = await review.seerr_directory(self.runtime)
|
||||
self.assertEqual(result["state"], "available")
|
||||
self.assertEqual(len(result["users"]), 101)
|
||||
self.assertEqual(get.await_args.kwargs["skip"], 100)
|
||||
for broken in [[], users[:2], users[:1] * 100]:
|
||||
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, return_value={"pageInfo": {"results": 101}, "results": broken}):
|
||||
self.assertEqual((await review.seerr_directory(self.runtime))["state"], "unavailable")
|
||||
|
||||
async def test_jellyfin_server_and_directory_must_agree(self):
|
||||
with patch.object(review.JellyfinClient, "get_system_info", new_callable=AsyncMock, return_value={"Id": SERVER}), \
|
||||
patch.object(review.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": JF, "Name": "Georgia", "ServerId": OTHER}]):
|
||||
self.assertEqual((await review.jellyfin_directory(self.runtime))["state"], "unavailable")
|
||||
|
||||
|
||||
class JellystatIdentityTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_unconfigured_client_never_calls_upstream(self):
|
||||
with patch("backend.app.clients.jellystat.httpx.AsyncClient") as http:
|
||||
result = await JellystatClient(None, None).check_user_ids([JF])
|
||||
http.assert_not_called()
|
||||
self.assertEqual(result[JF]["state"], "not_configured")
|
||||
|
||||
async def test_missing_wrong_and_failed_ids_are_distinguished_without_details(self):
|
||||
ids = [f"{i:032x}" for i in range(1, 6)]
|
||||
def handler(request):
|
||||
self.assertEqual(request.headers["x-api-token"], "PRIVATE")
|
||||
user_id = json.loads(request.content)["userid"]
|
||||
if user_id == ids[0]: return httpx.Response(200, json={"Id": user_id, "Name": "Georgia", "PRIVATE": "hidden"})
|
||||
if user_id == ids[1]: return httpx.Response(200, content=b"")
|
||||
if user_id == ids[2]: return httpx.Response(200, json={"Id": OTHER})
|
||||
if user_id == ids[3]: return httpx.Response(401, text="PRIVATE error")
|
||||
return httpx.Response(503, text="PRIVATE error")
|
||||
real = httpx.AsyncClient
|
||||
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
|
||||
data = await JellystatClient("http://jellystat", "PRIVATE").check_user_ids(ids)
|
||||
self.assertEqual([data[key]["state"] for key in ids], ["matched", "missing", "unavailable", "unavailable", "unavailable"])
|
||||
self.assertNotIn("PRIVATE", json.dumps(data))
|
||||
|
||||
|
||||
class IdentityRouteTests(unittest.TestCase):
|
||||
def client(self, role=None):
|
||||
app = FastAPI()
|
||||
app.include_router(identities.router)
|
||||
if role:
|
||||
app.dependency_overrides[get_current_user] = lambda: {"username": "viewer", "role": role}
|
||||
return TestClient(app)
|
||||
|
||||
def test_admin_only_read_and_write(self):
|
||||
for role, status in [(None, 401), ("user", 403)]:
|
||||
client = self.client(role)
|
||||
self.assertEqual(client.get("/admin/identities").status_code, status)
|
||||
self.assertEqual(client.post("/admin/identities/confirm", json={"revision": "a" * 64, "user_ids": [1]}).status_code, status)
|
||||
|
||||
def test_resolution_requires_admin_and_strict_ids(self):
|
||||
for endpoint in ['check', 'confirm']:
|
||||
body = {'user_id': 1, 'jellyfin_user_id': JF}
|
||||
if endpoint == 'confirm': body['revision'] = 'a' * 64
|
||||
for role, status in [(None, 401), ('user', 403)]:
|
||||
self.assertEqual(self.client(role).post('/admin/identities/resolve/' + endpoint, json=body).status_code, status)
|
||||
self.assertEqual(self.client(role).post('/admin/identities/repair/' + endpoint, json=body).status_code, status)
|
||||
for invalid in [{'user_id': True}, {'jellyfin_user_id': 'invalid'}, {'seerr_user_id': 22}]:
|
||||
self.assertEqual(self.client('admin').post('/admin/identities/resolve/' + endpoint, json={**body, **invalid}).status_code, 422)
|
||||
self.assertEqual(self.client('admin').post('/admin/identities/repair/' + endpoint, json={**body, **invalid}).status_code, 422)
|
||||
with patch.object(identities, 'resolve_identity', new_callable=AsyncMock, return_value={'row': {}}):
|
||||
result = self.client('admin').post('/admin/identities/resolve/check', json={'user_id': 1, 'jellyfin_user_id': JF})
|
||||
self.assertEqual(result.headers['cache-control'], 'no-store')
|
||||
|
||||
def test_no_store_and_no_browser_supplied_identity(self):
|
||||
with patch.object(identities, "review_identities", new_callable=AsyncMock, return_value=({"rows": []}, {}, None)):
|
||||
response = self.client("admin").get("/admin/identities")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
for body in [{"revision": "a" * 64, "user_ids": [1, 1]}, {"revision": "a" * 64, "user_ids": []},
|
||||
{"revision": "a" * 64, "user_ids": [1], "jellyfin_id": OTHER}]:
|
||||
self.assertEqual(self.client("admin").post("/admin/identities/confirm", json=body).status_code, 422)
|
||||
@@ -0,0 +1,48 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.services import jellyfin_sync
|
||||
from backend.app.services.jellyfin_identity import link_user, user_for_identity
|
||||
from backend.app.routers import admin
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class IdentitySyncTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_sync_reuses_id_when_names_differ_and_preserves_settings(self):
|
||||
db.create_user('old@example.test', 'Password-123456!', auth_provider='jellyseerr', jellyseerr_user_id=42,
|
||||
auto_search_enabled=False, email='kept@example.test')
|
||||
original = db.get_user_by_username('old@example.test')
|
||||
runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test')
|
||||
jf = SimpleNamespace(configured=lambda: True, get_users=AsyncMock(return_value=[{'Id': 'a' * 32, 'Name': 'NewName'}]))
|
||||
with patch.object(jellyfin_sync, 'get_runtime_settings', return_value=runtime), \
|
||||
patch.object(jellyfin_sync, 'JellyfinClient', return_value=jf), \
|
||||
patch.object(jellyfin_sync, 'get_cached_jellyseerr_users', return_value=[{'id': 42, 'jellyfinUserId': 'a' * 32, 'email': 'upstream@example.test'}]), \
|
||||
patch.object(jellyfin_sync, 'save_jellyfin_users_cache'):
|
||||
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
|
||||
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
|
||||
kept = user_for_identity('a' * 32, 'http://jf')
|
||||
self.assertEqual(kept['id'], original['id'])
|
||||
self.assertFalse(kept['auto_search_enabled'])
|
||||
self.assertEqual(kept['email'], 'kept@example.test')
|
||||
self.assertIsNone(db.get_user_by_username('NewName'))
|
||||
self.assertEqual(kept['auth_provider'], 'jellyfin')
|
||||
|
||||
async def test_resync_no_longer_deletes_accounts(self):
|
||||
db.create_user('Keep', 'Password-123456!')
|
||||
runtime = SimpleNamespace(jellyseerr_base_url='http://seer', jellyseerr_api_key='test')
|
||||
with patch.object(admin, 'get_runtime_settings', return_value=runtime), \
|
||||
patch.object(admin, '_fetch_all_jellyseerr_users', new=AsyncMock(return_value=[{'id': 42}])), \
|
||||
patch.object(jellyfin_sync, 'sync_jellyfin_users', new=AsyncMock(return_value=0)), \
|
||||
patch.object(admin, 'delete_non_admin_users') as delete:
|
||||
result = await admin.jellyseerr_users_resync()
|
||||
self.assertEqual(result['cleared'], 0)
|
||||
delete.assert_not_called()
|
||||
self.assertIsNotNone(db.get_user_by_username('Keep'))
|
||||
|
||||
def test_jellyfin_lookup_is_scoped_to_server(self):
|
||||
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin')
|
||||
link_user('Viewer', 'a' * 32, 'http://jf')
|
||||
self.assertIsNotNone(user_for_identity('a' * 32, 'http://jf'))
|
||||
self.assertIsNone(user_for_identity('a' * 32, 'http://other-server'))
|
||||
@@ -0,0 +1,201 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.clients.jellystat import HistoryLimitError, JellystatClient, JellystatError
|
||||
from backend.app.routers import admin, insights as router
|
||||
from backend.app.services import insights
|
||||
from backend.app.services.jellyfin_identity import link_user, linked_user_id
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
NOW = datetime(2026, 9, 7, 12, tzinfo=timezone.utc)
|
||||
USER = {"username": "viewer", "role": "user", "auth_provider": "jellyfin", "jellyseerr_user_id": 42}
|
||||
LIBRARIES = [{"Id": "movies", "CollectionType": "movies"}, {"Id": "music", "CollectionType": "music"}]
|
||||
|
||||
|
||||
def play(id="play-1", **extra):
|
||||
return {"Id": id, "UserId": "jf-viewer", "UserName": "PRIVATE NAME", "NowPlayingItemId": "movie-1",
|
||||
"NowPlayingItemName": "Arrival", "ParentId": "movies", "PlaybackDuration": 3600,
|
||||
"ActivityDateInserted": NOW.isoformat(), "RemoteEndPoint": "PRIVATE IP", "DeviceId": "PRIVATE DEVICE",
|
||||
"PlayState": {"secret": "PRIVATE STATE"}, "Client": "Jellyfin Web", "PlayMethod": "DirectPlay", **extra}
|
||||
|
||||
|
||||
class JellystatClientTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def history(self, handler, **kwargs):
|
||||
original = httpx.AsyncClient
|
||||
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **options: original(transport=httpx.MockTransport(handler), **options)):
|
||||
return await JellystatClient("http://jellystat/base", "secret-api-key").get_user_history(
|
||||
kwargs.get("user_id", "jf-viewer"), NOW - timedelta(days=7), NOW)
|
||||
|
||||
async def test_paginates_and_sends_only_backend_identity_and_header_credential(self):
|
||||
calls = []
|
||||
def handler(request):
|
||||
calls.append(request)
|
||||
self.assertEqual(request.headers["x-api-token"], "secret-api-key")
|
||||
self.assertNotIn("secret-api-key", str(request.url))
|
||||
if request.url.path == "/base/api/getLibraries":
|
||||
return httpx.Response(200, json=LIBRARIES)
|
||||
self.assertEqual(request.method, "POST")
|
||||
self.assertEqual(request.url.path, "/base/api/getUserHistory")
|
||||
self.assertEqual(json.loads(request.content), {"userid": "jf-viewer"})
|
||||
self.assertNotIn("search", request.url.params)
|
||||
self.assertEqual(json.loads(request.url.params["filters"])[0]["field"], "ActivityDateInserted")
|
||||
return httpx.Response(200, json={"pages": 2, "results": [play(request.url.params["page"])]})
|
||||
history, libraries = await self.history(handler)
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertEqual(len(history), 2)
|
||||
self.assertEqual(libraries, LIBRARIES)
|
||||
|
||||
async def test_rejects_foreign_history_malformed_responses_and_overflow(self):
|
||||
for payload, exception in [
|
||||
({"pages": 1, "results": [play(UserId="someone-else")]}, JellystatError),
|
||||
({"pages": 1, "results": [play(UserId=None)]}, JellystatError),
|
||||
({"results": []}, JellystatError),
|
||||
({"pages": 51, "results": []}, HistoryLimitError),
|
||||
({"pages": 2, "results": []}, JellystatError),
|
||||
({"pages": 0, "results": [play()]}, JellystatError),
|
||||
]:
|
||||
with self.subTest(payload=payload):
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=LIBRARIES if request.method == "GET" else payload)
|
||||
with self.assertRaises(exception):
|
||||
await self.history(handler)
|
||||
|
||||
async def test_empty_history_is_valid(self):
|
||||
result, _ = await self.history(lambda request: httpx.Response(200, json=LIBRARIES if request.method == "GET" else {"pages": 0, "results": []}))
|
||||
self.assertEqual(result, [])
|
||||
|
||||
async def test_upstream_failure_is_sanitized(self):
|
||||
with self.assertRaises(JellystatError) as error:
|
||||
await self.history(lambda _: httpx.Response(401, text="private upstream error"))
|
||||
self.assertNotIn("private", str(error.exception))
|
||||
self.assertNotIn("secret-api-key", str(error.exception))
|
||||
|
||||
|
||||
class SummaryTests(unittest.TestCase):
|
||||
def test_units_media_counts_deduplication_ranges_streaks_and_privacy(self):
|
||||
rows = [play(), play(), play("rewatch"),
|
||||
play("episode", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration="1200",
|
||||
ActivityDateInserted=(NOW - timedelta(days=1)).isoformat()),
|
||||
play("episode-rewatch", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration=1200,
|
||||
ActivityDateInserted=(NOW - timedelta(days=2)).isoformat()),
|
||||
play("song", ParentId="music", NowPlayingItemId="song-1", PlaybackDuration=180),
|
||||
play("old", ActivityDateInserted=(NOW - timedelta(days=8)).isoformat()),
|
||||
play("zero", PlaybackDuration=0)]
|
||||
data = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
self.assertEqual(data["summary"], {"minutes": 163, "plays": 5, "movies": 1, "episodes": 1,
|
||||
"active_days": 3, "current_streak": 3, "longest_streak": 3})
|
||||
self.assertAlmostEqual(sum(day["minutes"] for day in data["daily"]), 163)
|
||||
self.assertEqual(data["top_titles"][0]["title"], "Arrival")
|
||||
self.assertEqual(len(data["recent"]), 5)
|
||||
self.assertNotIn("PRIVATE", json.dumps(data))
|
||||
|
||||
def test_invalid_durations_do_not_become_zero_or_nan(self):
|
||||
for value in [-1, "NaN", "Infinity", "nonsense"]:
|
||||
with self.subTest(value=value), self.assertRaises(JellystatError):
|
||||
insights.summarize([play(PlaybackDuration=value)], LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
|
||||
def test_empty_history_has_zero_filled_days(self):
|
||||
result = insights.summarize([], LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
self.assertEqual(result["summary"]["minutes"], 0)
|
||||
self.assertEqual(len(result["daily"]), 8)
|
||||
self.assertEqual(result["summary"]["current_streak"], 0)
|
||||
|
||||
|
||||
class InsightsIntegrationTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
insights._cache.clear()
|
||||
db.create_user("viewer", "Test-Password123!", auth_provider="jellyfin")
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="jf-key",
|
||||
jellystat_base_url="http://jellystat", jellystat_api_key="stats-key")
|
||||
|
||||
async def test_identity_does_not_change_with_username_reuse_or_server_changes(self):
|
||||
link_user("viewer", "jf-original", "http://jellyfin/")
|
||||
link_user("viewer", "jf-replacement", "http://jellyfin")
|
||||
self.assertEqual(linked_user_id("viewer", "http://jellyfin"), "jf-original")
|
||||
self.assertIsNone(linked_user_id("viewer", "http://other-server"))
|
||||
|
||||
async def test_local_account_cannot_claim_same_name_and_verified_user_can_bootstrap(self):
|
||||
with patch.object(insights.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": "jf-viewer", "Name": "viewer"}]) as remote:
|
||||
self.assertIsNone(await insights.resolve_identity({**USER, "auth_provider": "local"}, self.runtime))
|
||||
remote.assert_not_called()
|
||||
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
|
||||
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
|
||||
self.assertEqual(remote.await_count, 1)
|
||||
|
||||
async def test_requests_use_seerr_id_even_when_name_matches_another_user(self):
|
||||
for request_id, seerr_id in [(1, 42), (2, 99)]:
|
||||
db.upsert_request_cache(request_id, request_id, "movie", 2, "Request", 2026,
|
||||
"viewer", "viewer", seerr_id, NOW.isoformat(), NOW.isoformat(), "{}")
|
||||
report = insights.request_summary(USER, NOW - timedelta(days=7), NOW)
|
||||
self.assertEqual(report["total"], 1)
|
||||
self.assertEqual(report["recent"][0]["request_id"], 1)
|
||||
|
||||
async def test_cache_isolated_by_identity_period_and_configuration(self):
|
||||
link_user("viewer", "jf-viewer", "http://jellyfin")
|
||||
db.create_user("second", "Test-Password123!", auth_provider="jellyfin")
|
||||
link_user("second", "jf-second", "http://jellyfin")
|
||||
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
|
||||
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote:
|
||||
await insights.get_insights(USER, 7)
|
||||
await insights.get_insights(USER, 7)
|
||||
self.assertEqual(remote.await_count, 1)
|
||||
await insights.get_insights({**USER, "username": "second"}, 7)
|
||||
await insights.get_insights(USER, 30)
|
||||
self.runtime.jellystat_api_key = "rotated-key"
|
||||
await insights.get_insights(USER, 7)
|
||||
self.assertEqual(remote.await_count, 4)
|
||||
|
||||
async def test_disabled_integration_never_calls_upstream(self):
|
||||
self.runtime.jellystat_api_key = None
|
||||
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
|
||||
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock) as remote:
|
||||
result = await insights.get_insights(USER, 30)
|
||||
self.assertEqual(result["state"], "not_configured")
|
||||
self.assertIsNone(result["summary"])
|
||||
remote.assert_not_called()
|
||||
|
||||
async def test_settings_mask_jellystat_credential(self):
|
||||
db.set_setting("jellystat_api_key", "private-stats-key")
|
||||
result = await admin.list_settings()
|
||||
setting = next(row for row in result["settings"] if row["key"] == "jellystat_api_key")
|
||||
self.assertTrue(setting["sensitive"])
|
||||
self.assertTrue(setting["isSet"])
|
||||
self.assertNotIn("private-stats-key", json.dumps(result))
|
||||
|
||||
|
||||
class InsightsRouteTests(unittest.TestCase):
|
||||
def app(self, authenticated=True):
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
if authenticated:
|
||||
app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
|
||||
return TestClient(app)
|
||||
|
||||
def test_requires_authentication(self):
|
||||
self.assertEqual(self.app(False).get("/insights").status_code, 401)
|
||||
|
||||
def test_query_accepts_period_and_forbids_identity_and_scope_overrides(self):
|
||||
with patch.object(router, "get_insights", new_callable=AsyncMock, return_value={"state": "ready"}) as report:
|
||||
client = self.app()
|
||||
for days in [7, 30, 90, 365]:
|
||||
response = client.get(f"/insights?days={days}")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
report.assert_awaited_with({**USER, "features": {"stats": True}}, 365)
|
||||
for query in ["days=-1", "days=999999", "days=invalid", "userid=other", "user_id=other", "scope=server"]:
|
||||
self.assertEqual(client.get(f"/insights?{query}").status_code, 422, query)
|
||||
|
||||
def test_errors_do_not_leak_upstream_details(self):
|
||||
with patch.object(router, "get_insights", new_callable=AsyncMock, side_effect=JellystatError("PRIVATE key and URL")):
|
||||
response = self.app().get("/insights")
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertNotIn("PRIVATE", response.text)
|
||||
@@ -0,0 +1,165 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app.routers import insights as router
|
||||
from backend.app.services import insights, insights_artwork as artwork
|
||||
from backend.tests.test_insights import NOW, LIBRARIES, USER, play
|
||||
|
||||
ITEM = "a" * 32
|
||||
OTHER = "b" * 32
|
||||
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jK1sAAAAASUVORK5CYII=")
|
||||
|
||||
|
||||
def transcode(activity, minutes, video_direct=False, audio_direct=False, hardware="nvenc", **kwargs):
|
||||
return play(activity, NowPlayingItemId=ITEM, PlaybackDuration=minutes * 60, PlayMethod="Transcode",
|
||||
TranscodingInfo={"IsVideoDirect": video_direct, "IsAudioDirect": audio_direct,
|
||||
"HardwareAccelerationType": hardware, "VideoCodec": "h264", "AudioCodec": "aac"}, **kwargs)
|
||||
|
||||
|
||||
class TranscodingSummaryTests(unittest.TestCase):
|
||||
def test_gpu_audio_software_and_unknown_time_are_separate_and_deduplicated(self):
|
||||
gpu = transcode("gpu", 10)
|
||||
rows = [gpu, dict(gpu), transcode("audio-only", 5, video_direct=True),
|
||||
transcode("software", 3, audio_direct=True, hardware="none"),
|
||||
transcode("remux", 2, video_direct=True, audio_direct=True),
|
||||
{**transcode("stale", 1), "PlayMethod": "DirectPlay"},
|
||||
transcode("unknown-hardware", 2, audio_direct=True, hardware=None),
|
||||
{**transcode("unknown-streams", 1), "TranscodingInfo": None},
|
||||
transcode("old", 100, ActivityDateInserted=(NOW - timedelta(days=31)).isoformat())]
|
||||
result = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=30), NOW)
|
||||
stats = result["transcoding"]
|
||||
self.assertEqual(stats["hardware_video_minutes"], 10)
|
||||
self.assertEqual(stats["audio_minutes"], 15)
|
||||
self.assertEqual(stats["video_minutes"], 15)
|
||||
self.assertEqual(stats["software_video_minutes"], 3)
|
||||
self.assertEqual(stats["unknown_hardware_minutes"], 2)
|
||||
self.assertEqual(stats["unknown_video_minutes"], 1)
|
||||
self.assertEqual(stats["unknown_audio_minutes"], 1)
|
||||
self.assertEqual(stats["hardware"], [{"name": "NVIDIA NVENC", "minutes": 10}])
|
||||
self.assertIsNone(stats["gpu_busy_minutes"])
|
||||
|
||||
def test_audio_media_cannot_accumulate_video_gpu_time(self):
|
||||
row = transcode("music", 4, ParentId="music")
|
||||
row["TranscodingInfo"]["VideoCodec"] = None
|
||||
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
|
||||
self.assertEqual(result["audio_minutes"], 4)
|
||||
self.assertEqual(result["video_minutes"], 0)
|
||||
self.assertEqual(result["hardware_video_minutes"], 0)
|
||||
|
||||
def test_direct_stream_counts_audio_but_does_not_claim_video_encoding(self):
|
||||
row = {**transcode("stream", 5), "PlayMethod": "DirectStream"}
|
||||
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
|
||||
self.assertEqual(result["audio_minutes"], 5)
|
||||
self.assertEqual(result["hardware_video_minutes"], 0)
|
||||
|
||||
def test_numeric_hardware_enum_and_legacy_json_are_supported(self):
|
||||
import json
|
||||
row = transcode("enum", 5, hardware=3)
|
||||
row["TranscodingInfo"] = json.dumps(row["TranscodingInfo"])
|
||||
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
|
||||
self.assertEqual(result["hardware_video_minutes"], 5)
|
||||
row["TranscodingInfo"] = "invalid JSON"
|
||||
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
|
||||
self.assertEqual(result["unknown_video_minutes"], 5)
|
||||
self.assertEqual(result["hardware_video_minutes"], 0)
|
||||
|
||||
def test_episode_artwork_uses_series_id_and_invalid_ids_are_ignored(self):
|
||||
rows = [play("episode", EpisodeId=OTHER, NowPlayingItemId=ITEM),
|
||||
play("invalid", NowPlayingItemId="../../secret")]
|
||||
result = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
indexed = {row["id"]: row for row in result["recent"]}
|
||||
self.assertEqual(indexed["episode"]["artwork_item_id"], ITEM)
|
||||
self.assertIsNone(indexed["invalid"]["artwork_item_id"])
|
||||
|
||||
|
||||
class ArtworkTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="PRIVATE-API-KEY")
|
||||
self.secret = patch.object(artwork.settings, "jwt_secret", "test-artwork-signing-secret")
|
||||
self.secret.start()
|
||||
self.addCleanup(self.secret.stop)
|
||||
artwork._cache.clear()
|
||||
artwork._downloads = asyncio.Semaphore(6)
|
||||
|
||||
def url_and_token(self, user=USER):
|
||||
data = {"recent": [{"id": "play-1", "artwork_item_id": ITEM}]}
|
||||
before = copy.deepcopy(data)
|
||||
result = artwork.with_artwork(data, user, self.runtime)
|
||||
self.assertEqual(data, before)
|
||||
row = result["recent"][0]
|
||||
self.assertNotIn("artwork_item_id", row)
|
||||
self.assertNotIn("PRIVATE-API-KEY", row["artwork_url"])
|
||||
return row["artwork_url"], parse_qs(urlsplit(row["artwork_url"]).query)["token"][0]
|
||||
|
||||
async def test_ticket_is_bound_to_user_item_server_credentials_and_time(self):
|
||||
with patch.object(artwork.time, "time", return_value=1000):
|
||||
_, token = self.url_and_token()
|
||||
artwork.verify_artwork_token(USER, self.runtime, ITEM, token)
|
||||
for user, runtime, media_id in [({**USER, "username": "different"}, self.runtime, ITEM),
|
||||
(USER, self.runtime, OTHER), (USER, SimpleNamespace(jellyfin_base_url="http://other", jellyfin_api_key="PRIVATE-API-KEY"), ITEM),
|
||||
(USER, SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="changed"), ITEM)]:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
artwork.verify_artwork_token(user, runtime, media_id, token)
|
||||
self.assertEqual(raised.exception.status_code, 403)
|
||||
with patch.object(artwork.time, "time", return_value=5000), self.assertRaises(HTTPException):
|
||||
artwork.verify_artwork_token(USER, self.runtime, ITEM, token)
|
||||
for invalid in ["invalid", "1.", "1." + "\u2603" * 64]:
|
||||
with self.assertRaises(HTTPException):
|
||||
artwork.verify_artwork_token(USER, self.runtime, ITEM, invalid)
|
||||
|
||||
async def test_private_proxy_returns_image_and_validates_before_cache_access(self):
|
||||
calls = []
|
||||
def handler(request):
|
||||
calls.append(request)
|
||||
self.assertEqual(request.url.path, f"/Items/{ITEM}/Images/Primary")
|
||||
self.assertNotIn("PRIVATE", str(request.url))
|
||||
self.assertEqual(request.headers["X-Emby-Token"], "PRIVATE-API-KEY")
|
||||
return httpx.Response(200, content=PNG, headers={"Content-Type": "image/png"})
|
||||
real = httpx.AsyncClient
|
||||
_, token = self.url_and_token()
|
||||
with patch.object(artwork.httpx, "AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
|
||||
self.assertEqual(await artwork.get_artwork(USER, self.runtime, ITEM, token), (PNG, "image/png"))
|
||||
self.assertEqual(await artwork.get_artwork(USER, self.runtime, ITEM, token), (PNG, "image/png"))
|
||||
with self.assertRaises(HTTPException):
|
||||
await artwork.get_artwork({**USER, "username": "someone-else"}, self.runtime, ITEM, token)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
async def test_non_images_missing_images_and_large_images_fail_closed(self):
|
||||
_, token = self.url_and_token()
|
||||
real = httpx.AsyncClient
|
||||
for status, body, mime in [(404, b"PRIVATE", "text/plain"), (200, b"<svg>PRIVATE</svg>", "image/svg+xml"),
|
||||
(200, b"x" * (artwork.MAX_IMAGE_BYTES + 1), "image/png")]:
|
||||
transport = httpx.MockTransport(lambda request: httpx.Response(status, content=body, headers={"Content-Type": mime}))
|
||||
with patch.object(artwork.httpx, "AsyncClient", side_effect=lambda **kwargs: real(transport=transport, **kwargs)):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
await artwork.get_artwork(USER, self.runtime, ITEM, token)
|
||||
self.assertEqual(raised.exception.status_code, 404)
|
||||
self.assertNotIn("PRIVATE", raised.exception.detail)
|
||||
self.assertEqual(len(artwork._cache), 0)
|
||||
|
||||
|
||||
class ArtworkRouteTests(unittest.TestCase):
|
||||
def test_authentication_and_private_response_headers(self):
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
client = TestClient(app)
|
||||
self.assertEqual(client.get(f"/insights/artwork/{ITEM}?token=invalid").status_code, 401)
|
||||
app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
|
||||
with patch.object(router, "get_runtime_settings", return_value=None), \
|
||||
patch.object(router, "get_artwork", new_callable=AsyncMock, return_value=(PNG, "image/png")):
|
||||
result = client.get(f"/insights/artwork/{ITEM}?token=fixture")
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.content, PNG)
|
||||
self.assertEqual(result.headers["cache-control"], "private, max-age=600")
|
||||
self.assertEqual(result.headers["vary"], "Cookie, Authorization")
|
||||
self.assertEqual(result.headers["x-content-type-options"], "nosniff")
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException, Response
|
||||
from backend.app import db
|
||||
from backend.app.routers import auth
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class InviteEmailSignupTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
async def signup(self, code, username, **extra):
|
||||
with patch.object(auth, 'get_runtime_settings', return_value=SimpleNamespace(jellyfin_base_url=None, jellyfin_api_key=None)), patch.object(auth, 'send_templated_email', new_callable=AsyncMock), patch.object(auth, 'create_access_token', return_value='test-token'):
|
||||
return await auth.signup({'invite_code': code, 'username': username, 'password': 'Strong-Test-Password123!', **extra}, Response())
|
||||
|
||||
async def test_email_invite_binds_account_and_cannot_be_reused(self):
|
||||
invite = db.create_signup_invite(code='EMAILTEST', recipient_email='recipient@example.com', max_uses=20)
|
||||
self.assertEqual(invite['max_uses'], 1)
|
||||
public = auth._public_invite_payload(invite)
|
||||
self.assertTrue(public['email_bound'])
|
||||
self.assertNotIn('recipient@example.com', str(public))
|
||||
await self.signup('EMAILTEST', 'first-user')
|
||||
self.assertEqual(db.get_user_by_username('first-user')['email'], 'recipient@example.com')
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('EMAILTEST', 'second-user')
|
||||
|
||||
async def test_email_invite_rejects_recipient_override(self):
|
||||
db.create_signup_invite(code='BOUNDTEST', recipient_email='recipient@example.com')
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('BOUNDTEST', 'override-user', email='different@example.com')
|
||||
self.assertEqual(db.get_signup_invite_by_code('BOUNDTEST')['use_count'], 0)
|
||||
|
||||
async def test_manual_invite_requires_and_saves_email(self):
|
||||
db.create_signup_invite(code='MANUALTEST', max_uses=3)
|
||||
for email in ['', 'invalid']:
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('MANUALTEST', 'manual-user', email=email)
|
||||
await self.signup('MANUALTEST', 'manual-user', email='manual@example.com')
|
||||
self.assertEqual(db.get_user_by_username('manual-user')['email'], 'manual@example.com')
|
||||
self.assertEqual(db.get_signup_invite_by_code('MANUALTEST')['remaining_uses'], 2)
|
||||
|
||||
async def test_failed_creation_releases_reservation(self):
|
||||
invite = db.create_signup_invite(code='FAILTEST', recipient_email='recipient@example.com')
|
||||
with patch.object(auth, 'create_user', side_effect=RuntimeError('test failure')):
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('FAILTEST', 'failed-user')
|
||||
self.assertEqual(db.get_signup_invite_by_id(invite['id'])['use_count'], 0)
|
||||
|
||||
async def test_single_use_reservation_is_atomic(self):
|
||||
invite = db.create_signup_invite(code='RACETEST', recipient_email='recipient@example.com')
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(db.reserve_signup_invite_use, [invite['id']] * 4))
|
||||
self.assertEqual(sum(results), 1)
|
||||
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.services import issue_resolution as service
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class IssueAcceptanceTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def issue(self):
|
||||
item = db.create_portal_item(kind="issue", title="Broken <movie>", description="Repair",
|
||||
created_by_username="reporter", created_by_id=None, status="in_progress", issue_type="broken_media")
|
||||
self.start(item["id"])
|
||||
return item
|
||||
|
||||
def start(self, item_id):
|
||||
db.add_portal_item_activity(item_id, event_type="replacement_started", actor_username="reporter",
|
||||
actor_role="user", message="New repair", metadata_json=json.dumps({"repairTracking": {"requestId": "12", "actionId": "replace_media"}}))
|
||||
|
||||
async def test_importing_or_unverified_media_does_not_email_reporter(self):
|
||||
self.issue()
|
||||
for phase in ["collecting", "indexing", "unavailable"]:
|
||||
with patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": False, "phase": phase})), patch.object(service, "begin_issue_confirmation", new=AsyncMock()) as begin:
|
||||
await service.process_active_media_repairs()
|
||||
begin.assert_not_awaited()
|
||||
|
||||
async def test_verified_repair_emails_once_and_no_requires_a_new_repair(self):
|
||||
item = self.issue()
|
||||
with (
|
||||
patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": True, "phase": "complete"})),
|
||||
patch.object(service, "_workflow_settings", return_value=(3, 2, "days")),
|
||||
patch.object(service, "get_user_by_username", return_value={"username": "reporter"}),
|
||||
patch.object(service, "resolve_user_delivery_email", return_value="reporter@example.test"),
|
||||
patch.object(service, "send_generic_email", new=AsyncMock()) as email,
|
||||
):
|
||||
await service.process_active_media_repairs()
|
||||
await service.process_active_media_repairs()
|
||||
self.assertEqual(email.await_count, 1)
|
||||
self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation")
|
||||
content = email.await_args.kwargs
|
||||
self.assertIn("YES — it works", content["body_html"])
|
||||
self.assertIn("NO — still broken", content["body_html"])
|
||||
self.assertIn(f"/issues/confirm/{item['id']}#yes", content["body_html"])
|
||||
self.assertIn("Broken <movie>", content["body_html"])
|
||||
self.assertNotIn("<movie>", content["body_html"])
|
||||
self.assertIn("Confirm your answer in Magent", content["body_text"])
|
||||
service.respond_to_issue_confirmation(item["id"], resolved=False, actor_username="reporter", actor_role="user")
|
||||
await service.process_active_media_repairs()
|
||||
await service.process_due_issue_confirmations()
|
||||
self.assertEqual(email.await_count, 1)
|
||||
self.assertEqual(db.get_portal_item(item["id"])["status"], "in_progress")
|
||||
self.start(item["id"])
|
||||
await service.process_active_media_repairs()
|
||||
self.assertEqual(email.await_count, 2)
|
||||
@@ -0,0 +1,30 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import httpx
|
||||
from backend.app.clients.jellyfin import JellyfinClient
|
||||
from backend.app.services.snapshot import jellyfin_item_matches_request
|
||||
from backend.app.models import RequestType
|
||||
|
||||
class JellyfinMatchingTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_search_includes_punctuation_variant_and_provider_metadata(self):
|
||||
calls=[]
|
||||
def handle(request):
|
||||
calls.append(request)
|
||||
items=[{'Id':'animated','Name':'Avatar: The Last Airbender','ProductionYear':2005,'ProviderIds':{'Tmdb':'246'}}] if ':' in request.url.params['SearchTerm'] else [{'Id':'live','Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'82452'}}]
|
||||
return httpx.Response(200,json={'Items':items})
|
||||
original=httpx.AsyncClient
|
||||
with patch('backend.app.clients.jellyfin.httpx.AsyncClient',side_effect=lambda **kw:original(transport=httpx.MockTransport(handle),**kw)):
|
||||
result=await JellyfinClient('http://jellyfin','test').search_items('Avatar: The Last Airbender',['Series'])
|
||||
self.assertEqual({i['Id'] for i in result['Items']},{'live','animated'})
|
||||
self.assertTrue(all('ProviderIds' in r.url.params['Fields'] for r in calls))
|
||||
matches=[i for i in result['Items'] if jellyfin_item_matches_request(i,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload={'tmdbId':82452})]
|
||||
self.assertEqual([i['Id'] for i in matches],['live'])
|
||||
|
||||
def test_fallback_rejects_remakes_prefixes_and_conflicting_ids(self):
|
||||
def match(item,payload=None):
|
||||
return jellyfin_item_matches_request(item,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload=payload)
|
||||
self.assertTrue(match({'Name':'Avatar the Last Airbender','ProductionYear':2024}))
|
||||
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2005}))
|
||||
self.assertFalse(match({'Name':'Avatar','ProductionYear':2024}))
|
||||
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'246'}},{'tmdbId':82452}))
|
||||
self.assertTrue(match({'Name':'Localized title','ProviderIds':{'Tmdb':'82452'}},{'tmdbId':82452}))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user