Compare commits

..

5 Commits

Author SHA1 Message Date
11193677cf update README 2025-08-15 13:39:51 +02:00
c03606e31d .gitignore Update 2025-08-15 13:31:33 +02:00
b36f42a7b9 prettier README / better duplicated notification handling 2025-08-15 13:27:29 +02:00
jschaufuss@leitwerk.de
3ba0f3ddcb added Support for ntfy and apprise 2025-08-15 13:02:19 +02:00
839fafdb33 fix duplicated email 2025-08-13 21:13:25 +02:00
17 changed files with 545 additions and 246 deletions

7
.gitignore vendored
View File

@@ -69,7 +69,12 @@ staticfiles/
media/ media/
# Database # Database
/db.sqlite3 .data/
data/
data/db.sqlite3
./db.sqlite3
db.sqlite3
.db.sqlite3
# Environment files # Environment files
.env.local .env.local

View File

@@ -10,6 +10,7 @@ requests = "*"
python-dateutil = "*" python-dateutil = "*"
django = "*" django = "*"
jellyfin-apiclient-python = "*" jellyfin-apiclient-python = "*"
apprise = "*"
[dev-packages] [dev-packages]

102
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "cea8d420f44ecbfb792b63223f9b9827af98bdff685d256b47053575dfb15b49" "sha256": "726c31f18af5284731c9d76b583e1d6d789a3a95277450b74c3deeb6836841e8"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -16,6 +16,15 @@
] ]
}, },
"default": { "default": {
"apprise": {
"hashes": [
"sha256:17dca8ad0a5b2063f6bae707979a51ca2cb374fcc66b0dd5c05a9d286dd40069",
"sha256:483122aee19a89a7b075ecd48ef11ae37d79744f7aeb450bcf985a9a6c28c988"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.9.4"
},
"asgiref": { "asgiref": {
"hashes": [ "hashes": [
"sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142",
@@ -117,6 +126,14 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==3.4.3" "version": "==3.4.3"
}, },
"click": {
"hashes": [
"sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202",
"sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"
],
"markers": "python_version >= '3.10'",
"version": "==8.2.1"
},
"django": { "django": {
"hashes": [ "hashes": [
"sha256:0745b25681b129a77aae3d4f6549b62d3913d74407831abaa0d9021a03954bae", "sha256:0745b25681b129a77aae3d4f6549b62d3913d74407831abaa0d9021a03954bae",
@@ -161,6 +178,22 @@
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.6'",
"version": "==1.11.0" "version": "==1.11.0"
}, },
"markdown": {
"hashes": [
"sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45",
"sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"
],
"markers": "python_version >= '3.9'",
"version": "==3.8.2"
},
"oauthlib": {
"hashes": [
"sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9",
"sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"
],
"markers": "python_version >= '3.8'",
"version": "==3.3.1"
},
"python-dateutil": { "python-dateutil": {
"hashes": [ "hashes": [
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
@@ -170,6 +203,65 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==2.9.0.post0" "version": "==2.9.0.post0"
}, },
"pyyaml": {
"hashes": [
"sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff",
"sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48",
"sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086",
"sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e",
"sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133",
"sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5",
"sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484",
"sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee",
"sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5",
"sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68",
"sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a",
"sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf",
"sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99",
"sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8",
"sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85",
"sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19",
"sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc",
"sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a",
"sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1",
"sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317",
"sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c",
"sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631",
"sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d",
"sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652",
"sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5",
"sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e",
"sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b",
"sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8",
"sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476",
"sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706",
"sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563",
"sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237",
"sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b",
"sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083",
"sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180",
"sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425",
"sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e",
"sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f",
"sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725",
"sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183",
"sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab",
"sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774",
"sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725",
"sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e",
"sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5",
"sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d",
"sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290",
"sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44",
"sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed",
"sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4",
"sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba",
"sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12",
"sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"
],
"markers": "python_version >= '3.8'",
"version": "==6.0.2"
},
"requests": { "requests": {
"hashes": [ "hashes": [
"sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c",
@@ -179,6 +271,14 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==2.32.4" "version": "==2.32.4"
}, },
"requests-oauthlib": {
"hashes": [
"sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36",
"sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"
],
"markers": "python_version >= '3.4'",
"version": "==2.0.0"
},
"six": { "six": {
"hashes": [ "hashes": [
"sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274",

311
README.md
View File

@@ -1,228 +1,133 @@
# Subscribarr # Subscribarr
# Subscribarr <p align="center">
<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/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">
<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">
</p>
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. <!-- Optional dynamic badges (uncomment and replace OWNER/REPO / IMAGE if you want):
<p align="center">
<a href="https://github.com/OWNER/REPO/releases"><img src="https://img.shields.io/github/v/release/OWNER/REPO?sort=semver" alt="latest release"></a>
<a href="https://hub.docker.com/r/OWNER/IMAGE"><img src="https://img.shields.io/docker/pulls/OWNER/IMAGE" alt="docker pulls"></a>
<a href="https://github.com/OWNER/REPO/commits/main"><img src="https://img.shields.io/github/commit-activity/m/OWNER/REPO" alt="commit activity"></a>
</p>
-->
--- Lightweight web UI for Sonarr/Radarr subscriptions with Jellyfin login, calendar, and flexible notifications via Email, ntfy, and Apprise.
## Screenshots
![Overview](screenshots/SCR-20250811-lfod.png)
![Settings](screenshots/SCR-20250811-lfrm.png)
![Subscriptions](screenshots/SCR-20250811-lfvc.png)
![Search](screenshots/SCR-20250811-lfyq.png)
![Details](screenshots/SCR-20250811-lgau.png)
![Notifications](screenshots/SCR-20250811-lgcz.png)
---
## Features ## Features
- Sign in with Jellyfin (no separate user store)
- Sonarr/Radarrstyle calendar (upcoming episodes/movies)
- Subscribe/unsubscribe from the UI (series & movies)
- Admin overview of all users subscriptions with posters
- Peruser notification channels:
- Email (SMTP)
- ntfy (Bearer token or Basic Auth)
- Apprise (Discord, Gotify, Pushover, Webhooks, and many more)
- Dockerready; environmentdriven security (ALLOWED_HOSTS, CSRF, proxy)
- **Login via Jellyfin** (use your Jellyfin account; admin status respected) ## Screenshots
- **Subscriptions** for series and movies; duplicate-send protection per user/day <p align="center">
- **Email notifications** (SMTP configurable) <img src="./screenshots/SCR-20250811-lfrm.png" alt="Settings" width="800"><br/>
- **Sonarr/Radarr integration** (calendar/status; optional file-presence check) <img src="./screenshots/SCR-20250811-lfvc.png" alt="Subscriptions" width="800"><br/>
- **Settings UI** for Jellyfin/Arr/Mail/Account <img src="./screenshots/SCR-20250811-lfod.png" alt="Overview" width="800"><br/>
- **Periodic check via cron** calling `manage.py check_new_media` <img src="./screenshots/SCR-20250811-lfyq.png" alt="Search" width="800"><br/>
<img src="./screenshots/SCR-20250811-lgau.png" alt="Details" width="800"><br/>
<img src="./screenshots/SCR-20250811-lgcz.png" alt="Notifications" width="800">
</p>
--- ## Quickstart (Docker Compose)
1) Ensure the lockfile matches your Pipfile (e.g., after adding packages):
## 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 ```bash
git clone https://gitea.js-devop.de/jschaufuss/Subscribarr.git pipenv lock
cd Subscribarr
docker compose up -d --build
``` ```
2) Build and run:
- 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 todays 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 ```bash
git clone https://gitea.js-devop.de/jschaufuss/Subscribarr.git docker compose build
cd Subscribarr docker compose up -d
``` ```
3) Open the app and complete the firstrun setup (Jellyfin + Arr URLs/keys).
### 2) Create & activate a virtualenv Important environment variables (examples):
- `DJANGO_ALLOWED_HOSTS=subscribarr.example.com,localhost,127.0.0.1`
- `DJANGO_CSRF_TRUSTED_ORIGINS=https://subscribarr.example.com,http://subscribarr.example.com`
- Reverse proxy/TLS:
- `USE_X_FORWARDED_HOST=true`
- `DJANGO_SECURE_PROXY_SSL_HEADER=true`
- `DJANGO_CSRF_COOKIE_SECURE=true`
- `DJANGO_SESSION_COOKIE_SECURE=true`
> Note: `DJANGO_CSRF_TRUSTED_ORIGINS` must include the exact scheme+host (+port if used).
### Local (Pipenv)
```bash ```bash
python -m venv .venv pipenv sync
# Linux/macOS: pipenv run python manage.py migrate
source .venv/bin/activate pipenv run python manage.py runserver
# Windows (PowerShell):
# .venv\Scripts\Activate.ps1
``` ```
### 3) Install dependencies (including Django) ## InApp Configuration
If the repository provides `requirements.txt`: - Settings → Jellyfin: server URL + API key
- Settings → Sonarr/Radarr: base URLs + API keys (with “Test” button)
- Settings → Mail server: SMTP (host/port/TLS/SSL/user/password/from)
- 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)
## ntfy Notes
- Server URL: e.g., `https://ntfy.sh` or your own server
- Auth:
- Bearer token (Authorization header)
- Basic Auth (username/password)
- Topic selection:
- Per user in the profile, or a global default topic in Settings
## Apprise Notes
Provide one or more destination URLs (one per line), e.g.:
- `gotify://TOKEN@gotify.example.com/`
- `discord://webhook_id/webhook_token`
- `mailto://user:pass@smtp.example.com`
- `pover://user@token`
- `json://webhook.example.com/path`
User URLs are added in addition to global defaults.
## Notification Logic
- Series: on the air date, Subscribarr checks Sonarr for the episode and only notifies when `hasFile` is true (downloaded/present).
- Movies: similar via Radarr `hasFile` and matching the release date (Digital/Disc/Cinema) for today.
- 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
pip install --upgrade pip wheel pipenv run python manage.py check_new_media
pip install -r requirements.txt
``` ```
If not, install the core stack explicitly: - In Docker:
```bash ```bash
pip install --upgrade pip wheel docker compose exec web python manage.py check_new_media
pip install "Django>=5" djangorestframework python-dotenv
# add any additional libs your project uses as needed
``` ```
### 4) Configure environment (dev) ## Security & Proxy
Create a `.env` (or export env vars) with at least: - Set `DJANGO_ALLOWED_HOSTS` to your hostnames.
```env - Include all used origins in `DJANGO_CSRF_TRUSTED_ORIGINS` (http/https and port where applicable).
DJANGO_DEBUG=true - Behind a reverse proxy with TLS: enable `USE_X_FORWARDED_HOST`, `DJANGO_SECURE_PROXY_SSL_HEADER`, and secure cookie flags.
DJANGO_SECRET_KEY=dev-secret
DJANGO_ALLOWED_HOSTS=*
DB_PATH=./data/db.sqlite3
```
Create the `data/` directory if it doesnt exist.
### 5) Database setup ## Tech Stack
```bash - Backend: Django 5 + DRF
python manage.py makemigrations - Integrations: Sonarr/Radarr (API v3)
python manage.py migrate - Auth: Jellyfin
``` - Notifications: SMTP, ntfy (HTTP), Apprise
- Frontend: Templates + FullCalendar
### 6) (Optional) Create a superuser for the Django admin - DB: SQLite (default)
```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`).

View File

@@ -14,9 +14,11 @@ class CustomUserChangeForm(UserChangeForm):
class Meta: class Meta:
model = User model = User
fields = ('email',) fields = ('email', 'notification_channel', 'ntfy_topic', 'apprise_url')
widgets = { widgets = {
'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'Email address'}), 'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'Email address'}),
'ntfy_topic': forms.TextInput(attrs={'class': 'text-input', 'placeholder': 'ntfy topic (optional)'}),
'apprise_url': forms.Textarea(attrs={'rows': 2, 'placeholder': 'apprise://... or other URL'}),
} }
class JellyfinLoginForm(forms.Form): class JellyfinLoginForm(forms.Form):

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_user_jellyfin_server_user_jellyfin_token_and_more'),
]
operations = [
migrations.AddField(
model_name='user',
name='notification_channel',
field=models.CharField(choices=[('email', 'Email'), ('ntfy', 'ntfy'), ('apprise', 'Apprise')], default='email', max_length=10),
),
migrations.AddField(
model_name='user',
name='ntfy_topic',
field=models.CharField(blank=True, max_length=200, null=True),
),
migrations.AddField(
model_name='user',
name='apprise_url',
field=models.TextField(blank=True, null=True),
),
]

View File

@@ -16,6 +16,24 @@ class User(AbstractUser):
jellyfin_user_id = models.CharField(max_length=100, blank=True, null=True) jellyfin_user_id = models.CharField(max_length=100, blank=True, null=True)
jellyfin_token = models.CharField(max_length=500, blank=True, null=True) jellyfin_token = models.CharField(max_length=500, blank=True, null=True)
jellyfin_server = models.CharField(max_length=200, blank=True, null=True) jellyfin_server = models.CharField(max_length=200, blank=True, null=True)
# Notifications
NOTIFY_EMAIL = 'email'
NOTIFY_NTFY = 'ntfy'
NOTIFY_APPRISE = 'apprise'
NOTIFY_CHOICES = [
(NOTIFY_EMAIL, 'Email'),
(NOTIFY_NTFY, 'ntfy'),
(NOTIFY_APPRISE, 'Apprise'),
]
notification_channel = models.CharField(
max_length=10,
choices=NOTIFY_CHOICES,
default=NOTIFY_EMAIL,
)
# Optional per-user targets/overrides
ntfy_topic = models.CharField(max_length=200, blank=True, null=True)
apprise_url = models.TextField(blank=True, null=True)
def check_jellyfin_admin(self): def check_jellyfin_admin(self):
"""Check if user is Jellyfin admin on the server""" """Check if user is Jellyfin admin on the server"""

View File

@@ -18,13 +18,26 @@
{% endif %} {% endif %}
<div class="profile-section"> <div class="profile-section">
<h3>Email address</h3> <h3>Notifications</h3>
<form method="post" class="profile-form compact-form"> <form method="post" class="profile-form compact-form">
{% csrf_token %} {% csrf_token %}
<div class="form-row"> <div class="form-row">
<label for="id_email">Email</label> <label for="id_email">Email</label>
{{ form.email }} {{ form.email }}
</div> </div>
<div class="form-row">
<label for="id_notification_channel">Channel</label>
{{ form.notification_channel }}
<div class="help">Email, ntfy, or Apprise</div>
</div>
<div class="form-row">
<label for="id_ntfy_topic">ntfy topic (optional)</label>
{{ form.ntfy_topic }}
</div>
<div class="form-row">
<label for="id_apprise_url">Apprise URL(s)</label>
{{ form.apprise_url }}
</div>
<button type="submit" class="btn-primary">Save</button> <button type="submit" class="btn-primary">Save</button>
</form> </form>

View File

@@ -26,7 +26,7 @@ def profile(request):
form = CustomUserChangeForm(request.POST, instance=request.user) form = CustomUserChangeForm(request.POST, instance=request.user)
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.success(request, 'Email saved.') messages.success(request, 'Profile saved.')
return redirect('accounts:profile') return redirect('accounts:profile')
else: else:
form = CustomUserChangeForm(instance=request.user) form = CustomUserChangeForm(instance=request.user)

View File

@@ -68,7 +68,7 @@ def send_notification_email(
release_type=None, release_type=None,
): ):
""" """
Sends a notification email to a user with extended details Sendet eine Benachrichtigungs-E-Mail an einen User mit erweiterten Details
""" """
eff = _set_runtime_email_settings() eff = _set_runtime_email_settings()
logger.info( logger.info(
@@ -94,7 +94,7 @@ def send_notification_email(
context = { context = {
'username': user.username, 'username': user.username,
'title': media_title, 'title': media_title,
'type': 'Series' if media_type == 'series' else 'Movie', 'type': 'Serie' if media_type == 'series' else 'Film',
'overview': overview, 'overview': overview,
'poster_url': poster_url, 'poster_url': poster_url,
'episode_title': episode_title, 'episode_title': episode_title,
@@ -105,17 +105,89 @@ def send_notification_email(
'release_type': release_type, 'release_type': release_type,
} }
subject = f"New {context['type']} available: {media_title}" subject = f"Neue {context['type']} verfügbar: {media_title}"
message = render_to_string('arr_api/email/new_media_notification.html', context) message = render_to_string('arr_api/email/new_media_notification.html', context)
send_mail( # Fallback to dispatch respecting user preference
subject=subject, try:
message=message, # strip HTML tags for body_text basic fallback
from_email=settings.DEFAULT_FROM_EMAIL, import re
recipient_list=[user.email], body_text = re.sub('<[^<]+?>', '', message)
html_message=message, except Exception:
fail_silently=False, body_text = message
) _dispatch_user_notification(user, subject=subject, body_text=body_text, html_message=message)
def _send_ntfy(user, title: str, message: str, click_url: str | None = None):
cfg = AppSettings.current()
base = (cfg.ntfy_server_url or '').strip().rstrip('/')
if not base:
return False
topic = (user.ntfy_topic or cfg.ntfy_topic_default or '').strip()
if not topic:
return False
url = f"{base}/{topic}"
headers = {"Title": title}
if click_url:
headers["Click"] = click_url
if cfg.ntfy_token:
headers["Authorization"] = f"Bearer {cfg.ntfy_token}"
elif cfg.ntfy_user and cfg.ntfy_password:
# basic auth via requests
auth = (cfg.ntfy_user, cfg.ntfy_password)
else:
auth = None
try:
r = requests.post(url, data=message.encode('utf-8'), headers=headers, timeout=8, auth=auth if 'auth' in locals() else None)
return r.status_code // 100 == 2
except Exception:
return False
def _send_apprise(user, title: str, message: str):
# Lazy import apprise, optional dependency
try:
import apprise
except Exception:
return False
cfg = AppSettings.current()
urls = []
if user.apprise_url:
urls.extend([u.strip() for u in str(user.apprise_url).splitlines() if u.strip()])
if cfg.apprise_default_url:
urls.extend([u.strip() for u in str(cfg.apprise_default_url).splitlines() if u.strip()])
if not urls:
return False
app = apprise.Apprise()
for u in urls:
app.add(u)
return app.notify(title=title, body=message)
def _dispatch_user_notification(user, subject: str, body_text: str, html_message: str | None = None, click_url: str | None = None):
channel = getattr(user, 'notification_channel', 'email') or 'email'
if channel == 'ntfy':
ok = _send_ntfy(user, title=subject, message=body_text, click_url=click_url)
if ok:
return True
# fallback to email
if channel == 'apprise':
ok = _send_apprise(user, title=subject, message=body_text)
if ok:
return True
# fallback to email
try:
send_mail(
subject=subject,
message=body_text,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
html_message=html_message,
fail_silently=False,
)
return True
except Exception:
return False
def _get_arr_cfg(): def _get_arr_cfg():
@@ -209,8 +281,8 @@ def get_todays_radarr_calendar():
def check_jellyfin_availability(user, media_id, media_type): def check_jellyfin_availability(user, media_id, media_type):
""" """
Replaced: We check availability via Sonarr/Radarr (hasFile), Ersetzt: Wir prüfen Verfügbarkeit über Sonarr/Radarr (hasFile),
which is reliable if Jellyfin scans the same folders. was zuverlässig ist, wenn Jellyfin dieselben Ordner scannt.
""" """
# user is unused here; kept for backward compatibility # user is unused here; kept for backward compatibility
if media_type == 'series': if media_type == 'series':
@@ -269,19 +341,29 @@ def check_and_notify_users():
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: if not sub.user.email:
continue continue
send_notification_email( # Build subject/body
user=sub.user, subj = f"New episode available: {sub.series_title} S{season:02d}E{number:02d}"
media_title=sub.series_title, body = f"{sub.series_title} S{season:02d}E{number:02d} is now available."
media_type='series', # Prefer HTML email rendering if channel falls back to email
overview=sub.series_overview, html = None
poster_url=ep.get('seriesPoster'), try:
episode_title=ep.get('title'), ctx = {
season=season, 'username': sub.user.username,
episode=number, 'title': sub.series_title,
air_date=ep.get('airDateUtc'), 'type': 'Serie',
) 'overview': sub.series_overview,
'poster_url': ep.get('seriesPoster'),
'episode_title': ep.get('title'),
'season': season,
'episode': number,
'air_date': ep.get('airDateUtc'),
}
html = render_to_string('arr_api/email/new_media_notification.html', ctx)
except Exception:
pass
ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
# mark as sent unless duplicates are allowed # mark as sent unless duplicates are allowed
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): if ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
SentNotification.objects.create( SentNotification.objects.create(
user=sub.user, user=sub.user,
media_id=sub.series_id, media_id=sub.series_id,
@@ -323,16 +405,26 @@ def check_and_notify_users():
except Exception: except Exception:
pass pass
send_notification_email( subj = f"New movie available: {sub.title}"
user=sub.user, if rel:
media_title=sub.title, subj += f" ({rel})"
media_type='movie', body = f"{sub.title} is now available."
overview=sub.overview, html = None
poster_url=it.get('posterUrl'), try:
year=it.get('year'), ctx = {
release_type=rel, 'username': sub.user.username,
) 'title': sub.title,
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): 'type': 'Film',
'overview': sub.overview,
'poster_url': it.get('posterUrl'),
'year': it.get('year'),
'release_type': rel,
}
html = render_to_string('arr_api/email/new_media_notification.html', ctx)
except Exception:
pass
ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
if ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
SentNotification.objects.create( SentNotification.objects.create(
user=sub.user, user=sub.user,
media_id=sub.movie_id, media_id=sub.movie_id,

View File

@@ -64,6 +64,21 @@ class ArrSettingsForm(forms.Form):
radarr_api_key = forms.CharField(label="Radarr API Key", required=False, radarr_api_key = forms.CharField(label="Radarr API Key", required=False,
widget=forms.PasswordInput(render_value=True, attrs=WIDE)) widget=forms.PasswordInput(render_value=True, attrs=WIDE))
class NotificationSettingsForm(forms.Form):
# ntfy
ntfy_server_url = forms.URLField(label="ntfy Server URL", required=False, widget=forms.URLInput(attrs=WIDE),
help_text="e.g., https://ntfy.sh")
ntfy_topic_default = forms.CharField(label="Default Topic", required=False, widget=forms.TextInput(attrs=WIDE))
ntfy_user = forms.CharField(label="ntfy Username", required=False)
ntfy_password = forms.CharField(label="ntfy Password", required=False, widget=forms.PasswordInput(render_value=True))
ntfy_token = forms.CharField(label="ntfy Bearer Token", required=False, widget=forms.PasswordInput(render_value=True))
# Apprise
apprise_default_url = forms.CharField(
label="Apprise URL(s)", required=False, widget=forms.Textarea(attrs={"rows": 3, "class": "input-wide"}),
help_text="One per line. See https://github.com/caronc/apprise/wiki for URL formats."
)
class MailSettingsForm(forms.Form): class MailSettingsForm(forms.Form):
mail_host = forms.CharField(label="Mail Host", required=False) mail_host = forms.CharField(label="Mail Host", required=False)
mail_port = forms.IntegerField(label="Mail Port", required=False, min_value=1, max_value=65535) mail_port = forms.IntegerField(label="Mail Port", required=False, min_value=1, max_value=65535)
@@ -83,3 +98,23 @@ class AccountForm(forms.Form):
email = forms.EmailField(label="Email", required=False) email = forms.EmailField(label="Email", required=False)
new_password = forms.CharField(label="New password", required=False, widget=forms.PasswordInput) new_password = forms.CharField(label="New password", required=False, widget=forms.PasswordInput)
repeat_password = forms.CharField(label="Repeat password", required=False, widget=forms.PasswordInput) repeat_password = forms.CharField(label="Repeat password", required=False, widget=forms.PasswordInput)
class NotificationSettingsForm(forms.Form):
# ntfy
ntfy_server_url = forms.URLField(label="ntfy Server URL", required=False,
widget=forms.URLInput(attrs=WIDE),
help_text="e.g. https://ntfy.sh or your self-hosted URL")
ntfy_topic_default = forms.CharField(label="Default topic", required=False,
widget=forms.TextInput(attrs=WIDE))
ntfy_user = forms.CharField(label="ntfy Username", required=False,
widget=forms.TextInput(attrs=WIDE))
ntfy_password = forms.CharField(label="ntfy Password", required=False,
widget=forms.PasswordInput(render_value=True, attrs=WIDE))
ntfy_token = forms.CharField(label="ntfy Bearer token", required=False,
widget=forms.PasswordInput(render_value=True, attrs=WIDE))
# Apprise
apprise_default_url = forms.CharField(label="Apprise URL(s)", required=False,
widget=forms.Textarea(attrs={"rows": 3, **WIDE}),
help_text="One URL per line. Will be used in addition to any user-provided URLs.")

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-13 19:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('settingspanel', '0003_alter_appsettings_mail_secure'),
]
operations = [
migrations.AlterField(
model_name='appsettings',
name='mail_secure',
field=models.CharField(blank=True, choices=[('', 'No TLS/SSL'), ('starttls', 'STARTTLS (Port 587)'), ('ssl', 'SSL/TLS (Port 465)'), ('tls', 'TLS (alias STARTTLS)')], max_length=10, null=True),
),
]

View File

@@ -0,0 +1,41 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('settingspanel', '0004_alter_appsettings_mail_secure'),
]
operations = [
migrations.AddField(
model_name='appsettings',
name='ntfy_server_url',
field=models.URLField(blank=True, null=True, help_text='Base URL of ntfy server, e.g. https://ntfy.sh'),
),
migrations.AddField(
model_name='appsettings',
name='ntfy_topic_default',
field=models.CharField(max_length=200, blank=True, null=True, help_text="Default topic if user hasn't set one"),
),
migrations.AddField(
model_name='appsettings',
name='ntfy_user',
field=models.CharField(max_length=255, blank=True, null=True),
),
migrations.AddField(
model_name='appsettings',
name='ntfy_password',
field=models.CharField(max_length=255, blank=True, null=True),
),
migrations.AddField(
model_name='appsettings',
name='ntfy_token',
field=models.CharField(max_length=255, blank=True, null=True, help_text='Bearer token, alternative to user/password'),
),
migrations.AddField(
model_name='appsettings',
name='apprise_default_url',
field=models.TextField(blank=True, null=True, help_text='Apprise URL(s). Multiple allowed, one per line.'),
),
]

View File

@@ -34,6 +34,16 @@ class AppSettings(models.Model):
acc_username = models.CharField(max_length=150, blank=True, null=True) acc_username = models.CharField(max_length=150, blank=True, null=True)
acc_email = models.EmailField(blank=True, null=True) acc_email = models.EmailField(blank=True, null=True)
# Notifications - NTFY
ntfy_server_url = models.URLField(blank=True, null=True, help_text="Base URL of ntfy server, e.g. https://ntfy.sh")
ntfy_topic_default = models.CharField(max_length=200, blank=True, null=True, help_text="Default topic if user hasn't set one")
ntfy_user = models.CharField(max_length=255, blank=True, null=True)
ntfy_password = models.CharField(max_length=255, blank=True, null=True)
ntfy_token = models.CharField(max_length=255, blank=True, null=True, help_text="Bearer token, alternative to user/password")
# Notifications - Apprise (default target URLs, optional)
apprise_default_url = models.TextField(blank=True, null=True, help_text="Apprise URL(s). Multiple allowed, one per line.")
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):

View File

@@ -90,6 +90,20 @@
<div class="row"><label>From</label>{{ mail_form.mail_from }}</div> <div class="row"><label>From</label>{{ mail_form.mail_from }}</div>
</div> </div>
<div class="card">
<h2>Notifications</h2>
<h3>ntfy</h3>
<div class="row"><label>Server URL</label>{{ notify_form.ntfy_server_url }}</div>
<div class="row"><label>Default topic</label>{{ notify_form.ntfy_topic_default }}</div>
<div class="row"><label>Username</label>{{ notify_form.ntfy_user }}</div>
<div class="row"><label>Password</label>{{ notify_form.ntfy_password }}</div>
<div class="row"><label>Bearer token</label>{{ notify_form.ntfy_token }}</div>
<h3 style="margin-top:12px;">Apprise</h3>
<div class="row"><label>Default URL(s)</label>{{ notify_form.apprise_default_url }}</div>
<div class="help">Users can also set their own ntfy topic or Apprise URLs in their profile.</div>
</div>
<div class="card"> <div class="card">
<h2>Account</h2> <h2>Account</h2>
<div class="row"><label>Username</label>{{ account_form.username }}</div> <div class="row"><label>Username</label>{{ account_form.username }}</div>

View File

@@ -2,7 +2,7 @@ from django.shortcuts import render, redirect
from django.views import View from django.views import View
from django.contrib import messages from django.contrib import messages
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from .forms import ArrSettingsForm, MailSettingsForm, AccountForm, FirstRunSetupForm, JellyfinSettingsForm from .forms import ArrSettingsForm, MailSettingsForm, AccountForm, FirstRunSetupForm, JellyfinSettingsForm, NotificationSettingsForm
from .models import AppSettings from .models import AppSettings
from django.http import JsonResponse from django.http import JsonResponse
from accounts.utils import jellyfin_admin_required from accounts.utils import jellyfin_admin_required
@@ -96,6 +96,14 @@ class SettingsView(View):
"mail_password": cfg.mail_password or "", "mail_password": cfg.mail_password or "",
"mail_from": cfg.mail_from or "", "mail_from": cfg.mail_from or "",
}), }),
"notify_form": NotificationSettingsForm(initial={
"ntfy_server_url": cfg.ntfy_server_url or "",
"ntfy_topic_default": cfg.ntfy_topic_default or "",
"ntfy_user": cfg.ntfy_user or "",
"ntfy_password": cfg.ntfy_password or "",
"ntfy_token": cfg.ntfy_token or "",
"apprise_default_url": cfg.apprise_default_url or "",
}),
"account_form": AccountForm(initial={ "account_form": AccountForm(initial={
"username": cfg.acc_username or "", "username": cfg.acc_username or "",
"email": cfg.acc_email or "", "email": cfg.acc_email or "",
@@ -106,13 +114,15 @@ class SettingsView(View):
jellyfin_form = JellyfinSettingsForm(request.POST) jellyfin_form = JellyfinSettingsForm(request.POST)
arr_form = ArrSettingsForm(request.POST) arr_form = ArrSettingsForm(request.POST)
mail_form = MailSettingsForm(request.POST) mail_form = MailSettingsForm(request.POST)
notify_form = NotificationSettingsForm(request.POST)
acc_form = AccountForm(request.POST) acc_form = AccountForm(request.POST)
if not (jellyfin_form.is_valid() and arr_form.is_valid() and mail_form.is_valid() and acc_form.is_valid()): if not (jellyfin_form.is_valid() and arr_form.is_valid() and mail_form.is_valid() and notify_form.is_valid() and acc_form.is_valid()):
return render(request, self.template_name, { return render(request, self.template_name, {
"jellyfin_form": jellyfin_form, "jellyfin_form": jellyfin_form,
"arr_form": arr_form, "arr_form": arr_form,
"mail_form": mail_form, "mail_form": mail_form,
"notify_form": notify_form,
"account_form": acc_form, "account_form": acc_form,
}) })
@@ -136,6 +146,14 @@ class SettingsView(View):
cfg.mail_password = mail_form.cleaned_data.get("mail_password") or None cfg.mail_password = mail_form.cleaned_data.get("mail_password") or None
cfg.mail_from = mail_form.cleaned_data.get("mail_from") or None cfg.mail_from = mail_form.cleaned_data.get("mail_from") or None
# Update Notification settings
cfg.ntfy_server_url = notify_form.cleaned_data.get("ntfy_server_url") or None
cfg.ntfy_topic_default = notify_form.cleaned_data.get("ntfy_topic_default") or None
cfg.ntfy_user = notify_form.cleaned_data.get("ntfy_user") or None
cfg.ntfy_password = notify_form.cleaned_data.get("ntfy_password") or None
cfg.ntfy_token = notify_form.cleaned_data.get("ntfy_token") or None
cfg.apprise_default_url = notify_form.cleaned_data.get("apprise_default_url") or None
# Update account settings # Update account settings
cfg.acc_username = acc_form.cleaned_data.get("username") or None cfg.acc_username = acc_form.cleaned_data.get("username") or None
cfg.acc_email = acc_form.cleaned_data.get("email") or None cfg.acc_email = acc_form.cleaned_data.get("email") or None

View File

@@ -183,4 +183,5 @@ DEFAULT_FROM_EMAIL = None # Will be set from AppSettings
# Notifications / Debug # Notifications / Debug
# If True, duplicate suppression is disabled and emails can be resent on every run. # If True, duplicate suppression is disabled and emails can be resent on every run.
NOTIFICATIONS_ALLOW_DUPLICATES = os.getenv('NOTIFICATIONS_ALLOW_DUPLICATES', 'True').lower() == 'true' # Default is False to avoid accidental duplicate mails in local/dev runs.
NOTIFICATIONS_ALLOW_DUPLICATES = os.getenv('NOTIFICATIONS_ALLOW_DUPLICATES', 'False').lower() == 'true'