Compare commits

...

14 Commits

Author SHA1 Message Date
2a62f37b8c fix E-Mail notifications 2025-08-16 22:15:19 +02:00
00d7fe60d5 updated README 2025-08-16 15:37:08 +02:00
ee3ebeeb93 updated README 2025-08-16 15:32:10 +02:00
39bcd35925 changed docker-compose to docker hub image 2025-08-16 15:23:29 +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
root
70c95f7976 fix cron 2 2025-08-12 00:32:04 +02:00
d95c4344c3 docker/entrypoint.sh aktualisiert 2025-08-11 21:15:24 +00:00
5aa9f26470 fix cron 2025-08-11 22:34:24 +02:00
7ef1df2304 README.md aktualisiert 2025-08-11 13:19:39 +00:00
1f7d4daeed README.md aktualisiert 2025-08-11 10:58:37 +00:00
root
8165f70d64 fix CSRF 2025-08-11 12:57:08 +02:00
22 changed files with 686 additions and 384 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",

312
README.md
View File

@@ -1,226 +1,126 @@
# 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">
<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/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 git clone https://github.com/jschaufuss/subscribarr.git
cd Subscribarr cd subscribarr
docker compose up -d --build
``` ```
2) run the container:
- 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
- DJANGO_ALLOWED_HOSTS=*
- DJANGO_SECRET_KEY=change-me
- DB_PATH=/app/data/db.sqlite3
- NOTIFICATIONS_ALLOW_DUPLICATES=false
# 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:
- subscribarr-data:/app/data
restart: unless-stopped
volumes:
subscribarr-data:
```
---
## 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>:8000`.
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 up -d
cd Subscribarr ```
3) Open the app and complete the firstrun setup (Jellyfin + Arr URLs/keys).
```
http://127.0.0.1:8081
``` ```
### 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).
## InApp Configuration
- 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 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
python -m venv .venv docker exec -it subscribarr python manage.py check_new_media
# Linux/macOS:
source .venv/bin/activate
# Windows (PowerShell):
# .venv\Scripts\Activate.ps1
``` ```
### 3) Install dependencies (including Django) ## Security & Proxy
If the repository provides `requirements.txt`: - Set `DJANGO_ALLOWED_HOSTS` to your hostnames.
```bash - Include all used origins in `DJANGO_CSRF_TRUSTED_ORIGINS` (http/https and port where applicable).
pip install --upgrade pip wheel - Behind a reverse proxy with TLS: enable `USE_X_FORWARDED_HOST`, `DJANGO_SECURE_PROXY_SSL_HEADER`, and secure cookie flags.
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) ## Tech Stack
Create a `.env` (or export env vars) with at least: - Backend: Django 5 + DRF
```env - Integrations: Sonarr/Radarr (API v3)
DJANGO_DEBUG=true - Auth: Jellyfin
DJANGO_SECRET_KEY=dev-secret - Notifications: SMTP, ntfy (HTTP), Apprise
DJANGO_ALLOWED_HOSTS=* - Frontend: Templates + FullCalendar
DB_PATH=./data/db.sqlite3 - DB: SQLite (default)
```
Create the `data/` directory if it doesnt 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`).

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

@@ -17,6 +17,24 @@ class User(AbstractUser):
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"""
from accounts.utils import JellyfinClient from accounts.utils import JellyfinClient

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

@@ -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}"))

View File

@@ -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__)
@@ -68,7 +69,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 +95,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 +106,91 @@ 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)
# Fallback to dispatch respecting user preference
try:
# strip HTML tags for body_text basic fallback
import re
body_text = re.sub('<[^<]+?>', '', message)
except Exception:
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:
# Ensure email backend is configured from AppSettings at runtime
_set_runtime_email_settings()
send_mail( send_mail(
subject=subject, subject=subject,
message=message, message=body_text,
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email], recipient_list=[user.email],
html_message=message, html_message=html_message,
fail_silently=False, fail_silently=False,
) )
return True
except Exception:
return False
def _get_arr_cfg(): def _get_arr_cfg():
@@ -209,8 +284,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':
@@ -254,61 +329,72 @@ 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: # Build subject/body
continue subj = f"New episode available: {sub.series_title} S{season:02d}E{number:02d}"
send_notification_email( body = f"{sub.series_title} S{season:02d}E{number:02d} is now available."
user=sub.user, # Prefer HTML email rendering if channel falls back to email
media_title=sub.series_title, html = None
media_type='series', try:
overview=sub.series_overview, ctx = {
poster_url=ep.get('seriesPoster'), 'username': sub.user.username,
episode_title=ep.get('title'), 'title': sub.series_title,
season=season, 'type': 'Serie',
episode=number, 'overview': sub.series_overview,
air_date=ep.get('airDateUtc'), 'poster_url': ep.get('seriesPoster'),
) 'episode_title': ep.get('title'),
# mark as sent unless duplicates are allowed 'season': season,
'episode': number,
'air_date': ep.get('airDateUtc'),
}
html = render_to_string('arr_api/email/new_media_notification.html', ctx)
except Exception:
pass
# Reserve duplicate token atomically, then dispatch; rollback on failure
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
SentNotification.objects.create( try:
with transaction.atomic():
token, created = SentNotification.objects.get_or_create(
user=sub.user, user=sub.user,
media_id=sub.series_id, media_id=sub.series_id,
media_type='series', media_type='series',
media_title=sub.series_title, air_date=today,
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)
if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
# allow retry on next run
try:
SentNotification.objects.filter(
user=sub.user,
media_id=sub.series_id,
media_type='series',
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:
@@ -323,23 +409,50 @@ 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,
'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
# Reserve duplicate token atomically, then dispatch; rollback on failure
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
SentNotification.objects.create( try:
with transaction.atomic():
token, created = SentNotification.objects.get_or_create(
user=sub.user, user=sub.user,
media_id=sub.movie_id, media_id=sub.movie_id,
media_type='movie', media_type='movie',
media_title=sub.title, air_date=today,
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)
if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
try:
SentNotification.objects.filter(
user=sub.user,
media_id=sub.movie_id,
media_type='movie',
air_date=today
).delete()
except Exception:
pass
def has_new_episode_today(series_id): def has_new_episode_today(series_id):

View File

@@ -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

View File

@@ -1,40 +1,17 @@
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
- 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.example.com,https://app.example.org" - 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:
- subscribarr-data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
volumes:
subscribarr-data:

View File

@@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
# Apply migrations # Apply migrations
python manage.py makemigrations
python manage.py migrate --noinput python manage.py migrate --noinput
# Create admin user if provided # Create admin user if provided
@@ -66,11 +67,20 @@ print("AppSettings seeded from environment (if provided)")
PY PY
# Setup cron if schedule provided # Setup cron if schedule provided
if [[ -n "${CRON_SCHEDULE:-}" ]]; then if [ -n "${CRON_SCHEDULE:-}" ]; then
echo "Configuring cron: ${CRON_SCHEDULE}" cat >/etc/cron.d/subscribarr <<EOF
echo "${CRON_SCHEDULE} cd /app && /usr/local/bin/python manage.py check_new_media >> /app/cron.log 2>&1" > /etc/cron.d/subscribarr SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# gleiche DB wie die App:
DB_PATH=${DB_PATH:-/app/data/db.sqlite3}
PYTHON=/usr/local/bin/python
# <m h dom mon dow> <user> <cmd>
${CRON_SCHEDULE} root cd /app && \$PYTHON manage.py migrate --noinput && \$PYTHON manage.py check_new_media >> /app/cron.log 2>&1
EOF
chmod 0644 /etc/cron.d/subscribarr chmod 0644 /etc/cron.d/subscribarr
crontab /etc/cron.d/subscribarr
/usr/sbin/cron /usr/sbin/cron
fi fi

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

@@ -116,6 +116,21 @@ if not CSRF_TRUSTED_ORIGINS:
CSRF_TRUSTED_ORIGINS = ['https://subscribarr.local.js-devop.de'] CSRF_TRUSTED_ORIGINS = ['https://subscribarr.local.js-devop.de']
USE_X_FORWARDED_HOST = os.getenv('USE_X_FORWARDED_HOST', 'False').lower() == 'true'
if os.getenv('DJANGO_SECURE_PROXY_SSL_HEADER', '').lower() in ('1', 'true', 'yes'):
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Secure cookies when served over HTTPS (optional)
CSRF_COOKIE_SECURE = os.getenv('DJANGO_CSRF_COOKIE_SECURE', 'False').lower() == 'true'
SESSION_COOKIE_SECURE = os.getenv('DJANGO_SESSION_COOKIE_SECURE', 'False').lower() == 'true'
# Optional cookie domain override (for subdomain setups)
_cookie_domain = os.getenv('DJANGO_COOKIE_DOMAIN', '').strip()
if _cookie_domain:
CSRF_COOKIE_DOMAIN = _cookie_domain
SESSION_COOKIE_DOMAIN = _cookie_domain
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/ # https://docs.djangoproject.com/en/5.2/topics/i18n/
@@ -168,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'