Compare commits
11 Commits
22c0627298
...
dev
Author | SHA1 | Date | |
---|---|---|---|
11193677cf | |||
c03606e31d | |||
b36f42a7b9 | |||
![]() |
3ba0f3ddcb | ||
839fafdb33 | |||
![]() |
70c95f7976 | ||
d95c4344c3 | |||
5aa9f26470 | |||
7ef1df2304 | |||
1f7d4daeed | |||
![]() |
8165f70d64 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||||
|
1
Pipfile
1
Pipfile
@@ -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
102
Pipfile.lock
generated
@@ -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",
|
||||||
|
309
README.md
309
README.md
@@ -1,226 +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
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
- Sign in with Jellyfin (no separate user store)
|
||||||
|
- Sonarr/Radarr‑style calendar (upcoming episodes/movies)
|
||||||
|
- Subscribe/unsubscribe from the UI (series & movies)
|
||||||
|
- Admin overview of all users’ subscriptions with posters
|
||||||
|
- Per‑user notification channels:
|
||||||
|
- Email (SMTP)
|
||||||
|
- ntfy (Bearer token or Basic Auth)
|
||||||
|
- Apprise (Discord, Gotify, Pushover, Webhooks, and many more)
|
||||||
|
- Docker‑ready; environment‑driven 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
|
|
||||||
- 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 today’s items via Sonarr/Radarr calendars.
|
|
||||||
- Email is sent only if the item is **present** (e.g., `hasFile`/downloaded) and not already recorded in the sent-log (duplicate guard).
|
|
||||||
- Cron is configured using `CRON_SCHEDULE` and runs `python manage.py check_new_media`. Output is typically logged to `/app/cron.log` in the container.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routes / Endpoints (selected)
|
|
||||||
|
|
||||||
- `GET /` — Overview page with search/filter and subscribe actions
|
|
||||||
- `GET/POST /settings/` — Jellyfin/Arr/Mail/Account configuration (auth required; admin for some actions)
|
|
||||||
- Example subscribe endpoints (subject to change):
|
|
||||||
- `POST /api/series/subscribe/<series_id>/`, `POST /api/series/unsubscribe/<series_id>/`
|
|
||||||
- `POST /api/movies/subscribe/<movie_id>/`, `POST /api/movies/unsubscribe/<movie_id>/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Local Development (without Docker)
|
|
||||||
|
|
||||||
> Requires Python 3.12+ (recommended).
|
|
||||||
|
|
||||||
### 1) Clone
|
|
||||||
```bash
|
```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 first‑run 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)
|
## In‑App 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 doesn’t 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`).
|
||||||
|
@@ -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):
|
||||||
|
26
accounts/migrations/0003_user_notifications.py
Normal file
26
accounts/migrations/0003_user_notifications.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@@ -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"""
|
||||||
|
@@ -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>
|
||||||
|
|
||||||
|
@@ -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)
|
||||||
|
@@ -6,7 +6,7 @@ class Command(BaseCommand):
|
|||||||
help = 'Checks for new media and sends notifications'
|
help = 'Checks for new media and sends notifications'
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
self.stdout.write(f'[{timezone.now()}] Starting media check...')
|
self.stdout.write(f'[{timezone.now()}] Starting media check...')
|
||||||
try:
|
try:
|
||||||
check_and_notify_users()
|
check_and_notify_users()
|
||||||
self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Media check finished successfully'))
|
self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Media check finished successfully'))
|
||||||
|
@@ -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,
|
||||||
|
@@ -8,11 +8,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Django
|
# Django
|
||||||
- DJANGO_DEBUG=true
|
- DJANGO_DEBUG=true
|
||||||
|
- USE_X_FORWARDED_HOST=true
|
||||||
|
- DJANGO_SECURE_PROXY_SSL_HEADER=true
|
||||||
|
- DJANGO_CSRF_COOKIE_SECURE=true
|
||||||
|
- DJANGO_SESSION_COOKIE_SECURE=true
|
||||||
- DJANGO_ALLOWED_HOSTS=*
|
- DJANGO_ALLOWED_HOSTS=*
|
||||||
- DJANGO_SECRET_KEY=change-me
|
- DJANGO_SECRET_KEY=change-me
|
||||||
- DB_PATH=/app/data/db.sqlite3
|
- 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)
|
# App Settings (optional, otherwise use first-run setup)
|
||||||
#- JELLYFIN_URL=
|
#- JELLYFIN_URL=
|
||||||
#- JELLYFIN_API_KEY=
|
#- JELLYFIN_API_KEY=
|
||||||
@@ -33,8 +37,5 @@ services:
|
|||||||
# 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:
|
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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.")
|
||||||
|
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
41
settingspanel/migrations/0005_appsettings_notifications.py
Normal file
41
settingspanel/migrations/0005_appsettings_notifications.py
Normal 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.'),
|
||||||
|
),
|
||||||
|
]
|
@@ -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):
|
||||||
|
@@ -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>
|
||||||
|
@@ -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
|
||||||
|
@@ -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'
|
||||||
|
Reference in New Issue
Block a user