Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
2a62f37b8c | |||
00d7fe60d5 | |||
ee3ebeeb93 | |||
39bcd35925 |
376
README.md
376
README.md
@@ -4,7 +4,7 @@
|
|||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License MIT"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License MIT"></a>
|
||||||
<img src="https://img.shields.io/badge/python-3.13-blue.svg" alt="Python 3.13">
|
<img src="https://img.shields.io/badge/python-3.13-blue.svg" alt="Python 3.13">
|
||||||
<img src="https://img.shields.io/badge/django-5.x-092e20?logo=django&logoColor=white" alt="Django 5">
|
<img src="https://img.shields.io/badge/django-5.x-092e20?logo=django&logoColor=white" alt="Django 5">
|
||||||
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker ready">
|
<a href="https://hub.docker.com/r/10010011/subscribarr"><img src="https://img.shields.io/docker/pulls/10010011/subscribarr" alt="docker pulls"></a>
|
||||||
<img src="https://img.shields.io/badge/ntfy-supported-4c1" alt="ntfy supported">
|
<img src="https://img.shields.io/badge/ntfy-supported-4c1" alt="ntfy supported">
|
||||||
<img src="https://img.shields.io/badge/Apprise-supported-4c1" alt="Apprise supported">
|
<img src="https://img.shields.io/badge/Apprise-supported-4c1" alt="Apprise supported">
|
||||||
</p>
|
</p>
|
||||||
@@ -17,346 +17,110 @@
|
|||||||
</p>
|
</p>
|
||||||
-->
|
-->
|
||||||
|
|
||||||
Ein leichtgewichtiges Web‑Frontend für Benachrichtigungen und Abos rund um Sonarr/Radarr – mit Jellyfin‑Login, Kalender, Abo‑Verwaltung und flexiblen Notifications per E‑Mail, ntfy und Apprise.
|
Lightweight web UI for Sonarr/Radarr subscriptions with Jellyfin login, calendar, and flexible notifications via Email, ntfy, and Apprise.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Jellyfin‑Login (kein eigener Userstore nötig)
|
- Sign in with Jellyfin (no separate user store)
|
||||||
- Kalender im Sonarr/Radarr‑Stil (kommende Episoden/Filme)
|
- Sonarr/Radarr‑style calendar (upcoming episodes/movies)
|
||||||
- Abonnieren/Abbestellen direkt aus dem UI (Serien & Filme)
|
- Subscribe/unsubscribe from the UI (series & movies)
|
||||||
- Admin‑Übersicht aller Abos je Nutzer inkl. Poster
|
- Admin overview of all users’ subscriptions with posters
|
||||||
- Benachrichtigungen pro Nutzer wählbar:
|
- Per‑user notification channels:
|
||||||
- E‑Mail (SMTP)
|
- Email (SMTP)
|
||||||
- ntfy (Token oder Basic Auth)
|
- ntfy (Bearer token or Basic Auth)
|
||||||
- Apprise (zahlreiche Ziele wie Discord, Gotify, Pushover, Webhooks u. v. m.)
|
- Apprise (Discord, Gotify, Pushover, Webhooks, and many more)
|
||||||
- Docker‑fertig, env‑gesteuerte Security‑Settings (ALLOWED_HOSTS, CSRF, Proxy)
|
- Docker‑ready; environment‑driven security (ALLOWED_HOSTS, CSRF, proxy)
|
||||||
|
|
||||||
## Schnellstart
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./screenshots/SCR-20250811-lfrm.png" alt="Screenshot 1" width="800"><br/>
|
<img src="./screenshots/SCR-20250811-lfrm.png" alt="Settings" width="800"><br/>
|
||||||
<img src="./screenshots/SCR-20250811-lfvc.png" alt="Screenshot 2" width="800"><br/>
|
<img src="./screenshots/SCR-20250811-lfvc.png" alt="Subscriptions" width="800"><br/>
|
||||||
<img src="./screenshots/SCR-20250811-lfod.png" alt="Screenshot 3" width="800"><br/>
|
<img src="./screenshots/SCR-20250811-lfod.png" alt="Overview" width="800"><br/>
|
||||||
<img src="./screenshots/SCR-20250811-lfyq.png" alt="Screenshot 4" width="800"><br/>
|
<img src="./screenshots/SCR-20250811-lfyq.png" alt="Search" width="800"><br/>
|
||||||
<img src="./screenshots/SCR-20250811-lgau.png" alt="Screenshot 5" width="800"><br/>
|
<img src="./screenshots/SCR-20250811-lgau.png" alt="Details" width="800"><br/>
|
||||||
<img src="./screenshots/SCR-20250811-lgcz.png" alt="Screenshot 6" width="800">
|
<img src="./screenshots/SCR-20250811-lgcz.png" alt="Notifications" width="800">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### Mit Docker Compose
|
## Quickstart (Docker Compose)
|
||||||
1) Lockfile aktuell halten (wenn `Pipfile` geändert wurde):
|
1) Ensure the lockfile matches your Pipfile (e.g., after adding packages):
|
||||||
```bash
|
```bash
|
||||||
pipenv lock
|
git clone https://github.com/jschaufuss/subscribarr.git
|
||||||
|
cd subscribarr
|
||||||
```
|
```
|
||||||
2) Image bauen/Starten:
|
2) run the container:
|
||||||
```bash
|
```bash
|
||||||
docker compose build
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
3) Öffne die App und führe das First‑Run‑Setup (Jellyfin + Arr‑URLs/Keys) durch.
|
3) Open the app and complete the first‑run setup (Jellyfin + Arr URLs/keys).
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8081
|
||||||
|
```
|
||||||
|
|
||||||
Wichtige Umgebungsvariablen (Beispiele):
|
Important environment variables (examples):
|
||||||
- `DJANGO_ALLOWED_HOSTS=subscribarr.example.com,localhost,127.0.0.1`
|
- `DJANGO_ALLOWED_HOSTS=subscribarr.example.com,localhost,127.0.0.1`
|
||||||
- `DJANGO_CSRF_TRUSTED_ORIGINS=https://subscribarr.example.com,http://subscribarr.example.com`
|
- `DJANGO_CSRF_TRUSTED_ORIGINS=https://subscribarr.example.com,http://subscribarr.example.com`
|
||||||
- Reverse‑Proxy/TLS:
|
- Reverse proxy/TLS:
|
||||||
- `USE_X_FORWARDED_HOST=true`
|
- `USE_X_FORWARDED_HOST=true`
|
||||||
- `DJANGO_SECURE_PROXY_SSL_HEADER=true`
|
- `DJANGO_SECURE_PROXY_SSL_HEADER=true`
|
||||||
- `DJANGO_CSRF_COOKIE_SECURE=true`
|
- `DJANGO_CSRF_COOKIE_SECURE=true`
|
||||||
- `DJANGO_SESSION_COOKIE_SECURE=true`
|
- `DJANGO_SESSION_COOKIE_SECURE=true`
|
||||||
|
|
||||||
> Hinweis: In `DJANGO_CSRF_TRUSTED_ORIGINS` muss Schema+Host (und ggf. Port) exakt stimmen.
|
> Note: `DJANGO_CSRF_TRUSTED_ORIGINS` must include the exact scheme+host (+port if used).
|
||||||
|
|
||||||
### Lokal (Pipenv)
|
## In‑App Configuration
|
||||||
```bash
|
- Settings → Jellyfin: server URL + API key
|
||||||
pipenv sync
|
- Settings →Sonarr/Radarr: base URLs + API keys (with “Test” button)
|
||||||
pipenv run python manage.py migrate
|
- Settings →Mail server: SMTP (host/port/TLS/SSL/user/password/from)
|
||||||
pipenv run python manage.py runserver
|
- Settings →Notifications:
|
||||||
```
|
- ntfy: server URL, default topic, Basic Auth or Bearer token
|
||||||
|
- Apprise: default URL(s) (one per line)
|
||||||
|
- Profile (per user):
|
||||||
|
- Choose channel: Email, ntfy, or Apprise
|
||||||
|
- ntfy topic (optional, overrides default)
|
||||||
|
- Apprise URL(s) (optional, appended to defaults)
|
||||||
|
|
||||||
## Konfiguration im UI
|
## ntfy Notes
|
||||||
- Einstellungen → Jellyfin: Server‑URL + API‑Key
|
- Server URL: e.g., `https://ntfy.sh` or your own server
|
||||||
- Einstellungen → Sonarr/Radarr: Base‑URLs + API‑Keys (inkl. „Test“-Knopf)
|
|
||||||
- Einstellungen → Mailserver: SMTP (Host/Port/TLS/SSL/Benutzer/Passwort/From)
|
|
||||||
- Einstellungen → Notifications:
|
|
||||||
- ntfy: Server‑URL, Default‑Topic, Basic‑Auth oder Bearer‑Token
|
|
||||||
- Apprise: Default‑URL(s) (eine pro Zeile)
|
|
||||||
- Profil (pro Nutzer):
|
|
||||||
- Kanal wählen: E‑Mail, ntfy oder Apprise
|
|
||||||
- ntfy Topic (optional, überschreibt Default)
|
|
||||||
- Apprise URL(s) (optional, ergänzen die Defaults)
|
|
||||||
|
|
||||||
## ntfy – Hinweise
|
|
||||||
- Server‑URL: z. B. `https://ntfy.sh` oder eigener Server
|
|
||||||
- Auth:
|
- Auth:
|
||||||
- Bearer‑Token (Header)
|
- Bearer token (Authorization header)
|
||||||
- Basic‑Auth (Benutzer/Passwort)
|
- Basic Auth (username/password)
|
||||||
- Topic:
|
- Topic selection:
|
||||||
- pro Nutzer frei wählbar (Profil) oder globales Default‑Topic (Einstellungen)
|
- Per user in the profile, or a global default topic in Settings
|
||||||
|
|
||||||
## Apprise – Hinweise
|
## Apprise Notes
|
||||||
- Trag eine oder mehrere Ziel‑URLs ein (pro Zeile), z. B.:
|
Provide one or more destination URLs (one per line), e.g.:
|
||||||
- `gotify://TOKEN@gotify.example.com/`
|
- `gotify://TOKEN@gotify.example.com/`
|
||||||
- `discord://webhook_id/webhook_token`
|
- `discord://webhook_id/webhook_token`
|
||||||
- `mailto://user:pass@smtp.example.com`
|
- `mailto://user:pass@smtp.example.com`
|
||||||
- `pover://user@token`
|
- `pover://user@token`
|
||||||
- `json://webhook.example.com/path`
|
- `json://webhook.example.com/path`
|
||||||
- Nutzer können eigene URLs ergänzen; die globalen Defaults bleiben zusätzlich aktiv.
|
|
||||||
|
|
||||||
## Benachrichtigungslogik
|
User URLs are added in addition to global defaults.
|
||||||
- Serien: Es wird pro Abo am Release‑Tag geprüft, ob die Episode bereits als Datei vorhanden ist (Sonarr `hasFile`).
|
|
||||||
- Filme: Analog über Radarr `hasFile` und Release‑Datum (Digital/Disc/Kino‐Tag).
|
|
||||||
- Doppelversand wird per `SentNotification` unterdrückt (täglich pro Item/Nutzer).
|
|
||||||
- Fallback: Wenn ntfy/Apprise scheitern, wird E‑Mail versendet (falls konfiguriert).
|
|
||||||
|
|
||||||
## Jobs / Manuell anstoßen
|
## Notification Logic
|
||||||
- Regelmäßiger Check per Management Command (z. B. via Cron):
|
- Series: on the air date, Subscribarr checks Sonarr for the episode and only notifies when episode is downloaded and present.
|
||||||
|
- Movies: similar via Radarr when movie is downloaded and present.
|
||||||
|
- Duplicate suppression: entries are recorded in `SentNotification` per user/title/day; if sending fails, no record is stored.
|
||||||
|
- Fallback: if ntfy/Apprise fail, Subscribarr falls back to Email (when configured).
|
||||||
|
|
||||||
|
## Jobs / Manual Trigger
|
||||||
|
- Periodic check via management command (e.g., cron):
|
||||||
```bash
|
```bash
|
||||||
pipenv run python manage.py check_new_media
|
docker exec -it subscribarr python manage.py check_new_media
|
||||||
```
|
|
||||||
- In Docker:
|
|
||||||
```bash
|
|
||||||
docker compose exec web python manage.py check_new_media
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sicherheit & Proxy
|
## Security & Proxy
|
||||||
- Setze `DJANGO_ALLOWED_HOSTS` auf deine(n) Hostnamen.
|
- Set `DJANGO_ALLOWED_HOSTS` to your hostnames.
|
||||||
- Füge alle genutzten Ursprünge in `DJANGO_CSRF_TRUSTED_ORIGINS` hinzu (http/https und Port beachten).
|
- Include all used origins in `DJANGO_CSRF_TRUSTED_ORIGINS` (http/https and port where applicable).
|
||||||
- Hinter Reverse‑Proxy TLS aktivieren: `USE_X_FORWARDED_HOST`, `DJANGO_SECURE_PROXY_SSL_HEADER`, Cookie‑Flags.
|
- Behind a reverse proxy with TLS: enable `USE_X_FORWARDED_HOST`, `DJANGO_SECURE_PROXY_SSL_HEADER`, and secure cookie flags.
|
||||||
|
|
||||||
## Tech‑Stack
|
## Tech Stack
|
||||||
- Backend: Django 5 + DRF
|
- Backend: Django 5 + DRF
|
||||||
- Integrationen: Sonarr/Radarr (API v3)
|
- Integrations: Sonarr/Radarr (API v3)
|
||||||
- Auth: Jellyfin
|
- Auth: Jellyfin
|
||||||
- Notifications: SMTP, ntfy (HTTP), Apprise
|
- Notifications: SMTP, ntfy (HTTP), Apprise
|
||||||
- Frontend: Templates + FullCalendar
|
- Frontend: Templates + FullCalendar
|
||||||
- DB: SQLite (default)
|
- DB: SQLite (default)
|
||||||
|
|
||||||
## Lizenz
|
|
||||||
MIT
|
|
||||||
# Subscribarr
|
|
||||||
|
|
||||||
# Subscribarr
|
|
||||||
|
|
||||||
Subscribarr is a notification tool for the *Arr ecosystem (Sonarr, Radarr) and Jellyfin. Users can subscribe to shows/movies; when new episodes/releases are available (and actually present), Subscribarr sends email notifications.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Login via Jellyfin** (use your Jellyfin account; admin status respected)
|
|
||||||
- **Subscriptions** for series and movies; duplicate-send protection per user/day
|
|
||||||
- **Email notifications** (SMTP configurable)
|
|
||||||
- **Sonarr/Radarr integration** (calendar/status; optional file-presence check)
|
|
||||||
- **Settings UI** for Jellyfin/Arr/Mail/Account
|
|
||||||
- **Periodic check via cron** calling `manage.py check_new_media`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture / Tech Stack
|
|
||||||
|
|
||||||
- **Backend:** Django + Django REST Framework
|
|
||||||
- **Apps (examples):** `arr_api`, `accounts`, `settingspanel`
|
|
||||||
- **Database:** SQLite by default (path configurable via env)
|
|
||||||
- **Auth:** Jellyfin API (admin mirrored from Jellyfin policy)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quickstart (Docker)
|
|
||||||
|
|
||||||
### 1) Clone & run
|
|
||||||
```bash
|
|
||||||
git clone https://gitea.js-devop.de/jschaufuss/Subscribarr.git
|
|
||||||
cd Subscribarr
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
- Default app port inside the container: **8000**
|
|
||||||
- Optional: set `CRON_SCHEDULE` (e.g., `*/30 * * * *`) to enable periodic checks
|
|
||||||
|
|
||||||
### 2) Minimal `docker-compose.yml` (example)
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
services:
|
|
||||||
subscribarr:
|
|
||||||
build: .
|
|
||||||
container_name: subscribarr
|
|
||||||
ports:
|
|
||||||
- "8081:8000"
|
|
||||||
environment:
|
|
||||||
# Django
|
|
||||||
- DJANGO_DEBUG=true
|
|
||||||
- USE_X_FORWARDED_HOST=true
|
|
||||||
- DJANGO_SECURE_PROXY_SSL_HEADER=true
|
|
||||||
- DJANGO_CSRF_COOKIE_SECURE=true
|
|
||||||
- DJANGO_SESSION_COOKIE_SECURE=true
|
|
||||||
- DJANGO_ALLOWED_HOSTS=*
|
|
||||||
- DJANGO_SECRET_KEY=change-me
|
|
||||||
- DB_PATH=/app/data/db.sqlite3
|
|
||||||
- NOTIFICATIONS_ALLOW_DUPLICATES=false
|
|
||||||
- DJANGO_CSRF_TRUSTED_ORIGINS="https://subscribarr.local.js-devop.de"
|
|
||||||
# App Settings (optional, otherwise use first-run setup)
|
|
||||||
#- JELLYFIN_URL=
|
|
||||||
#- JELLYFIN_API_KEY=
|
|
||||||
#- SONARR_URL=
|
|
||||||
#- SONARR_API_KEY=
|
|
||||||
#- RADARR_URL=
|
|
||||||
#- RADARR_API_KEY=
|
|
||||||
#- MAIL_HOST=
|
|
||||||
#- MAIL_PORT=
|
|
||||||
#- MAIL_SECURE=
|
|
||||||
#- MAIL_USER=
|
|
||||||
#- MAIL_PASSWORD=
|
|
||||||
#- MAIL_FROM=
|
|
||||||
# Admin bootstrap (optional)
|
|
||||||
#- ADMIN_USERNAME=
|
|
||||||
#- ADMIN_PASSWORD=
|
|
||||||
#- ADMIN_EMAIL=
|
|
||||||
# Cron schedule (default every 30min)
|
|
||||||
- CRON_SCHEDULE=*/30 * * * *
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Variables (selection)
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `DJANGO_DEBUG` | `true` / `false` (disable in production). |
|
|
||||||
| `DJANGO_ALLOWED_HOSTS` | Comma list of allowed hosts (e.g., `example.com,localhost`). |
|
|
||||||
| `DJANGO_SECRET_KEY` | Django secret key. |
|
|
||||||
| `DB_PATH` | SQLite path, e.g., `/app/data/db.sqlite3`. |
|
|
||||||
| `NOTIFICATIONS_ALLOW_DUPLICATES` | Allow duplicate sends (`true`/`false`). |
|
|
||||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL` | Optional: bootstrap an admin user on first run. |
|
|
||||||
| `JELLYFIN_URL` / `JELLYFIN_API_KEY` | Base URL + API key for Jellyfin. |
|
|
||||||
| `SONARR_URL` / `SONARR_API_KEY` | Base URL + API key for Sonarr. |
|
|
||||||
| `RADARR_URL` / `RADARR_API_KEY` | Base URL + API key for Radarr. |
|
|
||||||
| `MAIL_HOST` / `MAIL_PORT` / `MAIL_SECURE` | SMTP host/port/security (`starttls` / `ssl` / empty). |
|
|
||||||
| `MAIL_USER` / `MAIL_PASSWORD` / `MAIL_FROM` | SMTP auth + sender address. |
|
|
||||||
| `CRON_SCHEDULE` | Cron interval for periodic checks (e.g., `*/30 * * * *`). |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## First Run
|
|
||||||
|
|
||||||
1. Start the container (or dev server) and open `http://<host>:8081`.
|
|
||||||
2. Complete the **first-time setup**: Jellyfin URL/API key (required), optional Sonarr/Radarr, SMTP.
|
|
||||||
3. **Sign in** with Jellyfin credentials (admin users in Jellyfin become admins in Subscribarr).
|
|
||||||
4. Adjust settings later at `/settings/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notifications & Cron
|
|
||||||
|
|
||||||
- The periodic job calls `check_new_media` which determines today’s items via Sonarr/Radarr calendars.
|
|
||||||
- Email is sent only if the item is **present** (e.g., `hasFile`/downloaded) and not already recorded in the sent-log (duplicate guard).
|
|
||||||
- Cron is configured using `CRON_SCHEDULE` and runs `python manage.py check_new_media`. Output is typically logged to `/app/cron.log` in the container.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routes / Endpoints (selected)
|
|
||||||
|
|
||||||
- `GET /` — Overview page with search/filter and subscribe actions
|
|
||||||
- `GET/POST /settings/` — Jellyfin/Arr/Mail/Account configuration (auth required; admin for some actions)
|
|
||||||
- Example subscribe endpoints (subject to change):
|
|
||||||
- `POST /api/series/subscribe/<series_id>/`, `POST /api/series/unsubscribe/<series_id>/`
|
|
||||||
- `POST /api/movies/subscribe/<movie_id>/`, `POST /api/movies/unsubscribe/<movie_id>/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Local Development (without Docker)
|
|
||||||
|
|
||||||
> Requires Python 3.12+ (recommended).
|
|
||||||
|
|
||||||
### 1) Clone
|
|
||||||
```bash
|
|
||||||
git clone https://gitea.js-devop.de/jschaufuss/Subscribarr.git
|
|
||||||
cd Subscribarr
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2) Create & activate a virtualenv
|
|
||||||
```bash
|
|
||||||
python -m venv .venv
|
|
||||||
# Linux/macOS:
|
|
||||||
source .venv/bin/activate
|
|
||||||
# Windows (PowerShell):
|
|
||||||
# .venv\Scripts\Activate.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3) Install dependencies (including Django)
|
|
||||||
If the repository provides `requirements.txt`:
|
|
||||||
```bash
|
|
||||||
pip install --upgrade pip wheel
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
If not, install the core stack explicitly:
|
|
||||||
```bash
|
|
||||||
pip install --upgrade pip wheel
|
|
||||||
pip install "Django>=5" djangorestframework python-dotenv
|
|
||||||
# add any additional libs your project uses as needed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4) Configure environment (dev)
|
|
||||||
Create a `.env` (or export env vars) with at least:
|
|
||||||
```env
|
|
||||||
DJANGO_DEBUG=true
|
|
||||||
DJANGO_SECRET_KEY=dev-secret
|
|
||||||
DJANGO_ALLOWED_HOSTS=*
|
|
||||||
DB_PATH=./data/db.sqlite3
|
|
||||||
```
|
|
||||||
Create the `data/` directory if it doesn’t exist.
|
|
||||||
|
|
||||||
### 5) Database setup
|
|
||||||
```bash
|
|
||||||
python manage.py makemigrations
|
|
||||||
python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6) (Optional) Create a superuser for the Django admin
|
|
||||||
```bash
|
|
||||||
python manage.py createsuperuser
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7) Run the dev server
|
|
||||||
```bash
|
|
||||||
python manage.py runserver 0.0.0.0:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model (high level)
|
|
||||||
|
|
||||||
- **User** (`accounts.User`): custom user with Jellyfin link and admin flag.
|
|
||||||
- **Subscriptions** (`arr_api.SeriesSubscription`, `arr_api.MovieSubscription`): unique per user/title.
|
|
||||||
- **SentNotification**: records delivered emails to avoid duplicates.
|
|
||||||
- **AppSettings**: singleton for Jellyfin/Arr/Mail/Account configuration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Production Notes
|
|
||||||
|
|
||||||
- Set **`DEBUG=false`**, a strong **`DJANGO_SECRET_KEY`**, and proper **`DJANGO_ALLOWED_HOSTS`**.
|
|
||||||
- Run behind a reverse proxy with HTTPS.
|
|
||||||
- Collect static files if served by Django:
|
|
||||||
```bash
|
|
||||||
python manage.py collectstatic --noinput
|
|
||||||
```
|
|
||||||
- Use a persistent database volume (or switch to Postgres/MySQL) for production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT (see `LICENSE`).
|
MIT (see `LICENSE`).
|
||||||
|
|
||||||
|
44
arr_api/management/commands/send_test_email.py
Normal file
44
arr_api/management/commands/send_test_email.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from arr_api.notifications import send_notification_email
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Send a test notification email to verify SMTP configuration"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--to', required=True, help='Recipient email address')
|
||||||
|
parser.add_argument('--username', default='testuser', help='Username to associate with the email')
|
||||||
|
parser.add_argument('--type', default='movie', choices=['movie', 'series'], help='Media type for the template')
|
||||||
|
parser.add_argument('--title', default='Subscribarr Test', help='Title to show in the email')
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
User = get_user_model()
|
||||||
|
email = opts['to']
|
||||||
|
username = opts['username']
|
||||||
|
media_type = opts['type']
|
||||||
|
title = opts['title']
|
||||||
|
|
||||||
|
user, _ = User.objects.get_or_create(username=username, defaults={'email': email})
|
||||||
|
if user.email != email:
|
||||||
|
user.email = email
|
||||||
|
user.save(update_fields=['email'])
|
||||||
|
|
||||||
|
# Use current time as air_date for nicer formatting
|
||||||
|
send_notification_email(
|
||||||
|
user=user,
|
||||||
|
media_title=title,
|
||||||
|
media_type=media_type,
|
||||||
|
overview='This is a test email from Subscribarr to verify your mail settings.',
|
||||||
|
poster_url=None,
|
||||||
|
episode_title='Pilot' if media_type == 'series' else None,
|
||||||
|
season=1 if media_type == 'series' else None,
|
||||||
|
episode=1 if media_type == 'series' else None,
|
||||||
|
air_date=timezone.now(),
|
||||||
|
year=timezone.now().year if media_type == 'movie' else None,
|
||||||
|
release_type='Test'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Test email queued/sent to {email}"))
|
@@ -7,6 +7,7 @@ from settingspanel.models import AppSettings
|
|||||||
import requests
|
import requests
|
||||||
from dateutil.parser import isoparse
|
from dateutil.parser import isoparse
|
||||||
import logging
|
import logging
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -177,6 +178,8 @@ def _dispatch_user_notification(user, subject: str, body_text: str, html_message
|
|||||||
return True
|
return True
|
||||||
# fallback to email
|
# fallback to email
|
||||||
try:
|
try:
|
||||||
|
# Ensure email backend is configured from AppSettings at runtime
|
||||||
|
_set_runtime_email_settings()
|
||||||
send_mail(
|
send_mail(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
message=body_text,
|
message=body_text,
|
||||||
@@ -326,21 +329,10 @@ def check_and_notify_users():
|
|||||||
if season is None or number is None:
|
if season is None or number is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# duplicate guard (per series per day per user)
|
# duplicate guard will be handled atomically before dispatch
|
||||||
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
|
||||||
already_notified = SentNotification.objects.filter(
|
|
||||||
media_id=sub.series_id,
|
|
||||||
media_type='series',
|
|
||||||
air_date=today,
|
|
||||||
user=sub.user
|
|
||||||
).exists()
|
|
||||||
if already_notified:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# check availability via Sonarr hasFile
|
# check availability via Sonarr hasFile
|
||||||
if sonarr_episode_has_file(sub.series_id, season, number):
|
if sonarr_episode_has_file(sub.series_id, season, number):
|
||||||
if not sub.user.email:
|
|
||||||
continue
|
|
||||||
# Build subject/body
|
# Build subject/body
|
||||||
subj = f"New episode available: {sub.series_title} S{season:02d}E{number:02d}"
|
subj = f"New episode available: {sub.series_title} S{season:02d}E{number:02d}"
|
||||||
body = f"{sub.series_title} S{season:02d}E{number:02d} is now available."
|
body = f"{sub.series_title} S{season:02d}E{number:02d} is now available."
|
||||||
@@ -361,36 +353,48 @@ def check_and_notify_users():
|
|||||||
html = render_to_string('arr_api/email/new_media_notification.html', ctx)
|
html = render_to_string('arr_api/email/new_media_notification.html', ctx)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Reserve duplicate token atomically, then dispatch; rollback on failure
|
||||||
|
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
token, created = SentNotification.objects.get_or_create(
|
||||||
|
user=sub.user,
|
||||||
|
media_id=sub.series_id,
|
||||||
|
media_type='series',
|
||||||
|
air_date=today,
|
||||||
|
defaults={'media_title': sub.series_title}
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
# race or DB error -> skip to avoid duplicates
|
||||||
|
continue
|
||||||
ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
|
ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
|
||||||
# mark as sent unless duplicates are allowed
|
if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
||||||
if ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
# allow retry on next run
|
||||||
SentNotification.objects.create(
|
try:
|
||||||
user=sub.user,
|
SentNotification.objects.filter(
|
||||||
media_id=sub.series_id,
|
user=sub.user,
|
||||||
media_type='series',
|
media_id=sub.series_id,
|
||||||
media_title=sub.series_title,
|
media_type='series',
|
||||||
air_date=today
|
air_date=today
|
||||||
)
|
).delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Film-Abos
|
# Film-Abos
|
||||||
for sub in MovieSubscription.objects.select_related('user').all():
|
for sub in MovieSubscription.objects.select_related('user').all():
|
||||||
it = movie_idx.get(sub.movie_id)
|
it = movie_idx.get(sub.movie_id)
|
||||||
|
# Fallback: if movie_id missing, try match by title
|
||||||
|
if not it and getattr(sub, 'title', None):
|
||||||
|
for _mid, _it in movie_idx.items():
|
||||||
|
if (_it.get('title') or '').strip().lower() == (sub.title or '').strip().lower():
|
||||||
|
it = _it
|
||||||
|
break
|
||||||
if not it:
|
if not it:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
|
||||||
already_notified = SentNotification.objects.filter(
|
|
||||||
media_id=sub.movie_id,
|
|
||||||
media_type='movie',
|
|
||||||
air_date=today,
|
|
||||||
user=sub.user
|
|
||||||
).exists()
|
|
||||||
if already_notified:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if radarr_movie_has_file(sub.movie_id):
|
if radarr_movie_has_file(sub.movie_id):
|
||||||
if not sub.user.email:
|
|
||||||
continue
|
|
||||||
# detect which release matched today
|
# detect which release matched today
|
||||||
rel = None
|
rel = None
|
||||||
try:
|
try:
|
||||||
@@ -423,15 +427,32 @@ def check_and_notify_users():
|
|||||||
html = render_to_string('arr_api/email/new_media_notification.html', ctx)
|
html = render_to_string('arr_api/email/new_media_notification.html', ctx)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Reserve duplicate token atomically, then dispatch; rollback on failure
|
||||||
|
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
token, created = SentNotification.objects.get_or_create(
|
||||||
|
user=sub.user,
|
||||||
|
media_id=sub.movie_id,
|
||||||
|
media_type='movie',
|
||||||
|
air_date=today,
|
||||||
|
defaults={'media_title': sub.title}
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
|
ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
|
||||||
if ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
||||||
SentNotification.objects.create(
|
try:
|
||||||
user=sub.user,
|
SentNotification.objects.filter(
|
||||||
media_id=sub.movie_id,
|
user=sub.user,
|
||||||
media_type='movie',
|
media_id=sub.movie_id,
|
||||||
media_title=sub.title,
|
media_type='movie',
|
||||||
air_date=today
|
air_date=today
|
||||||
)
|
).delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def has_new_episode_today(series_id):
|
def has_new_episode_today(series_id):
|
||||||
|
@@ -81,9 +81,13 @@ class ArrIndexView(View):
|
|||||||
eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()]
|
eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()]
|
||||||
movies = [m for m in movies if q in (m.get("title") or "").lower()]
|
movies = [m for m in movies if q in (m.get("title") or "").lower()]
|
||||||
|
|
||||||
# Abonnierte Serien und Filme laden
|
# Abonnierte Serien und Filme pro aktuellem Nutzer
|
||||||
subscribed_series_ids = set(SeriesSubscription.objects.values_list('series_id', flat=True))
|
if request.user.is_authenticated:
|
||||||
subscribed_movie_ids = set(MovieSubscription.objects.values_list('movie_id', flat=True))
|
subscribed_series_ids = set(SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True))
|
||||||
|
subscribed_movie_ids = set(MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True))
|
||||||
|
else:
|
||||||
|
subscribed_series_ids = set()
|
||||||
|
subscribed_movie_ids = set()
|
||||||
|
|
||||||
# Gruppierung nach Serie
|
# Gruppierung nach Serie
|
||||||
groups = defaultdict(lambda: {
|
groups = defaultdict(lambda: {
|
||||||
@@ -197,70 +201,7 @@ class CalendarEventsApi(APIView):
|
|||||||
return Response({"events": events})
|
return Response({"events": events})
|
||||||
|
|
||||||
|
|
||||||
class SubscribeSeriesView(View):
|
|
||||||
@method_decorator(require_POST)
|
|
||||||
def post(self, request, series_id):
|
|
||||||
series_data = {
|
|
||||||
'series_id': series_id,
|
|
||||||
'series_title': request.POST.get('series_title'),
|
|
||||||
'series_poster': request.POST.get('series_poster'),
|
|
||||||
'series_overview': request.POST.get('series_overview'),
|
|
||||||
'series_genres': request.POST.getlist('series_genres[]', [])
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription, created = SeriesSubscription.objects.get_or_create(
|
|
||||||
series_id=series_id,
|
|
||||||
defaults=series_data
|
|
||||||
)
|
|
||||||
|
|
||||||
if created:
|
|
||||||
messages.success(request, f'Subscribed to series "{series_data["series_title"]}"!')
|
|
||||||
else:
|
|
||||||
messages.info(request, f'Series "{series_data["series_title"]}" was already subscribed.')
|
|
||||||
|
|
||||||
return redirect('arr_api:index')
|
|
||||||
|
|
||||||
class UnsubscribeSeriesView(View):
|
|
||||||
@method_decorator(require_POST)
|
|
||||||
def post(self, request, series_id):
|
|
||||||
subscription = get_object_or_404(SeriesSubscription, series_id=series_id)
|
|
||||||
series_title = subscription.series_title
|
|
||||||
subscription.delete()
|
|
||||||
messages.success(request, f'Subscription for "{series_title}" has been removed.')
|
|
||||||
return redirect('arr_api:index')
|
|
||||||
|
|
||||||
class SubscribeMovieView(View):
|
|
||||||
@method_decorator(require_POST)
|
|
||||||
def post(self, request, movie_id):
|
|
||||||
movie_data = {
|
|
||||||
'movie_id': movie_id,
|
|
||||||
'title': request.POST.get('title'),
|
|
||||||
'poster': request.POST.get('poster'),
|
|
||||||
'overview': request.POST.get('overview'),
|
|
||||||
'genres': request.POST.getlist('genres[]', []),
|
|
||||||
'release_date': request.POST.get('release_date')
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription, created = MovieSubscription.objects.get_or_create(
|
|
||||||
movie_id=movie_id,
|
|
||||||
defaults=movie_data
|
|
||||||
)
|
|
||||||
|
|
||||||
if created:
|
|
||||||
messages.success(request, f'Subscribed to movie "{movie_data["title"]}"!')
|
|
||||||
else:
|
|
||||||
messages.info(request, f'Movie "{movie_data["title"]}" was already subscribed.')
|
|
||||||
|
|
||||||
return redirect('arr_api:index')
|
|
||||||
|
|
||||||
class UnsubscribeMovieView(View):
|
|
||||||
@method_decorator(require_POST)
|
|
||||||
def post(self, request, movie_id):
|
|
||||||
subscription = get_object_or_404(MovieSubscription, movie_id=movie_id)
|
|
||||||
movie_title = subscription.title
|
|
||||||
subscription.delete()
|
|
||||||
messages.success(request, f'Subscription for "{movie_title}" has been removed.')
|
|
||||||
return redirect('arr_api:index')
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
|
@@ -1,39 +1,15 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
subscribarr:
|
subscribarr:
|
||||||
build: .
|
image: 10010011/subscribarr:latest
|
||||||
container_name: subscribarr
|
container_name: subscribarr
|
||||||
ports:
|
ports:
|
||||||
- "8081:8000"
|
- "8081:8000"
|
||||||
environment:
|
environment:
|
||||||
# Django
|
|
||||||
- DJANGO_DEBUG=true
|
|
||||||
- USE_X_FORWARDED_HOST=true
|
|
||||||
- DJANGO_SECURE_PROXY_SSL_HEADER=true
|
|
||||||
- DJANGO_CSRF_COOKIE_SECURE=true
|
|
||||||
- DJANGO_SESSION_COOKIE_SECURE=true
|
|
||||||
- DJANGO_ALLOWED_HOSTS=*
|
- DJANGO_ALLOWED_HOSTS=*
|
||||||
- DJANGO_SECRET_KEY=change-me
|
- DJANGO_SECRET_KEY=change-me
|
||||||
- DB_PATH=/app/data/db.sqlite3
|
|
||||||
- NOTIFICATIONS_ALLOW_DUPLICATES=false
|
- NOTIFICATIONS_ALLOW_DUPLICATES=false
|
||||||
- DJANGO_CSRF_TRUSTED_ORIGINS="https://subscribarr.local.js-devop.de"
|
- DJANGO_CSRF_TRUSTED_ORIGINS="https://subscribarr.local.js-devop.de"
|
||||||
# App Settings (optional, otherwise use first-run setup)
|
|
||||||
#- JELLYFIN_URL=
|
|
||||||
#- JELLYFIN_API_KEY=
|
|
||||||
#- SONARR_URL=
|
|
||||||
#- SONARR_API_KEY=
|
|
||||||
#- RADARR_URL=
|
|
||||||
#- RADARR_API_KEY=
|
|
||||||
#- MAIL_HOST=
|
|
||||||
#- MAIL_PORT=
|
|
||||||
#- MAIL_SECURE=
|
|
||||||
#- MAIL_USER=
|
|
||||||
#- MAIL_PASSWORD=
|
|
||||||
#- MAIL_FROM=
|
|
||||||
# Admin bootstrap (optional)
|
|
||||||
#- ADMIN_USERNAME=
|
|
||||||
#- ADMIN_PASSWORD=
|
|
||||||
#- ADMIN_EMAIL=
|
|
||||||
# Cron schedule (default every 30min)
|
# Cron schedule (default every 30min)
|
||||||
- CRON_SCHEDULE=*/30 * * * *
|
- CRON_SCHEDULE=*/30 * * * *
|
||||||
volumes:
|
volumes:
|
||||||
|
Reference in New Issue
Block a user