added Support for ntfy and apprise

This commit is contained in:
jschaufuss@leitwerk.de
2025-08-15 13:02:19 +02:00
parent 839fafdb33
commit defbe0dc9c
14 changed files with 443 additions and 103 deletions

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",

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

@@ -2,7 +2,6 @@ from django.core.mail import send_mail
from django.conf import settings from django.conf import settings
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils import timezone from django.utils import timezone
from django.db import transaction
from settingspanel.models import AppSettings from settingspanel.models import AppSettings
# from accounts.utils import JellyfinClient # not needed for availability; use Sonarr/Radarr instead # from accounts.utils import JellyfinClient # not needed for availability; use Sonarr/Radarr instead
import requests import requests
@@ -69,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(
@@ -95,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,
@@ -106,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)
# 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:
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():
@@ -210,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':
@@ -255,56 +326,51 @@ 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)
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: if not sub.user.email:
continue continue
# After confirming availability, reserve once per user/series/day # Build subject/body
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): subj = f"New episode available: {sub.series_title} S{season:02d}E{number:02d}"
body = f"{sub.series_title} S{season:02d}E{number:02d} is now available."
# Prefer HTML email rendering if channel falls back to email
html = None
try: try:
with transaction.atomic(): ctx = {
obj, created = SentNotification.objects.get_or_create( 'username': sub.user.username,
user=sub.user, 'title': sub.series_title,
media_id=sub.series_id, 'type': 'Serie',
media_type='series', 'overview': sub.series_overview,
air_date=today, 'poster_url': ep.get('seriesPoster'),
defaults={ 'episode_title': ep.get('title'),
'media_title': sub.series_title, 'season': season,
'episode': number,
'air_date': ep.get('airDateUtc'),
} }
) html = render_to_string('arr_api/email/new_media_notification.html', ctx)
if not created:
# already reserved/sent
continue
except Exception:
# if DB error (race), skip to avoid duplicates
continue
try:
send_notification_email(
user=sub.user,
media_title=sub.series_title,
media_type='series',
overview=sub.series_overview,
poster_url=ep.get('seriesPoster'),
episode_title=ep.get('title'),
season=season,
episode=number,
air_date=ep.get('airDateUtc'),
)
except Exception:
# roll back reservation so we can retry next run
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
try:
SentNotification.objects.filter(
user=sub.user,
media_id=sub.series_id,
media_type='series',
air_date=today,
).delete()
except Exception: except Exception:
pass pass
continue _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
# no-op: already reserved via get_or_create above # mark as sent unless duplicates are allowed
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
SentNotification.objects.create(
user=sub.user,
media_id=sub.series_id,
media_type='series',
media_title=sub.series_title,
air_date=today
)
# Film-Abos # Film-Abos
for sub in MovieSubscription.objects.select_related('user').all(): for sub in MovieSubscription.objects.select_related('user').all():
@@ -312,6 +378,16 @@ def check_and_notify_users():
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: if not sub.user.email:
continue continue
@@ -329,47 +405,33 @@ def check_and_notify_users():
except Exception: except Exception:
pass pass
# After confirming availability, reserve once per user/movie/day subj = f"New movie available: {sub.title}"
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): if rel:
subj += f" ({rel})"
body = f"{sub.title} is now available."
html = None
try: try:
with transaction.atomic(): ctx = {
obj, created = SentNotification.objects.get_or_create( 'username': sub.user.username,
user=sub.user, 'title': sub.title,
media_id=sub.movie_id, 'type': 'Film',
media_type='movie', 'overview': sub.overview,
air_date=today, 'poster_url': it.get('posterUrl'),
defaults={ 'year': it.get('year'),
'media_title': sub.title, 'release_type': rel,
} }
) html = render_to_string('arr_api/email/new_media_notification.html', ctx)
if not created:
continue
except Exception:
continue
try:
send_notification_email(
user=sub.user,
media_title=sub.title,
media_type='movie',
overview=sub.overview,
poster_url=it.get('posterUrl'),
year=it.get('year'),
release_type=rel,
)
except Exception:
if 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: except Exception:
pass pass
continue _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
# no-op: already reserved via get_or_create above if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
SentNotification.objects.create(
user=sub.user,
media_id=sub.movie_id,
media_type='movie',
media_title=sub.title,
air_date=today
)
def has_new_episode_today(series_id): def has_new_episode_today(series_id):

BIN
data/db.sqlite3 Normal file

Binary file not shown.

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