From defbe0dc9cd81638cd8a109c9d5f27926ca6802f Mon Sep 17 00:00:00 2001 From: "jschaufuss@leitwerk.de" Date: Fri, 15 Aug 2025 13:02:19 +0200 Subject: [PATCH] added Support for ntfy and apprise --- Pipfile | 1 + Pipfile.lock | 102 ++++++- accounts/forms.py | 4 +- .../migrations/0003_user_notifications.py | 26 ++ accounts/models.py | 18 ++ accounts/templates/accounts/profile.html | 15 +- accounts/views.py | 2 +- arr_api/notifications.py | 256 +++++++++++------- data/db.sqlite3 | Bin 0 -> 180224 bytes settingspanel/forms.py | 35 +++ .../0005_appsettings_notifications.py | 41 +++ settingspanel/models.py | 10 + .../templates/settingspanel/settings.html | 14 + settingspanel/views.py | 22 +- 14 files changed, 443 insertions(+), 103 deletions(-) create mode 100644 accounts/migrations/0003_user_notifications.py create mode 100644 data/db.sqlite3 create mode 100644 settingspanel/migrations/0005_appsettings_notifications.py diff --git a/Pipfile b/Pipfile index 084e0a1..2afd56f 100644 --- a/Pipfile +++ b/Pipfile @@ -10,6 +10,7 @@ requests = "*" python-dateutil = "*" django = "*" jellyfin-apiclient-python = "*" +apprise = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index cff858e..f873c04 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "cea8d420f44ecbfb792b63223f9b9827af98bdff685d256b47053575dfb15b49" + "sha256": "726c31f18af5284731c9d76b583e1d6d789a3a95277450b74c3deeb6836841e8" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,15 @@ ] }, "default": { + "apprise": { + "hashes": [ + "sha256:17dca8ad0a5b2063f6bae707979a51ca2cb374fcc66b0dd5c05a9d286dd40069", + "sha256:483122aee19a89a7b075ecd48ef11ae37d79744f7aeb450bcf985a9a6c28c988" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==1.9.4" + }, "asgiref": { "hashes": [ "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", @@ -117,6 +126,14 @@ "markers": "python_version >= '3.7'", "version": "==3.4.3" }, + "click": { + "hashes": [ + "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", + "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b" + ], + "markers": "python_version >= '3.10'", + "version": "==8.2.1" + }, "django": { "hashes": [ "sha256:0745b25681b129a77aae3d4f6549b62d3913d74407831abaa0d9021a03954bae", @@ -161,6 +178,22 @@ "markers": "python_version >= '3.6'", "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": { "hashes": [ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", @@ -170,6 +203,65 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "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": { "hashes": [ "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", @@ -179,6 +271,14 @@ "markers": "python_version >= '3.8'", "version": "==2.32.4" }, + "requests-oauthlib": { + "hashes": [ + "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", + "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9" + ], + "markers": "python_version >= '3.4'", + "version": "==2.0.0" + }, "six": { "hashes": [ "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", diff --git a/accounts/forms.py b/accounts/forms.py index 022e3c5..6fedb7a 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -14,9 +14,11 @@ class CustomUserChangeForm(UserChangeForm): class Meta: model = User - fields = ('email',) + fields = ('email', 'notification_channel', 'ntfy_topic', 'apprise_url') widgets = { '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): diff --git a/accounts/migrations/0003_user_notifications.py b/accounts/migrations/0003_user_notifications.py new file mode 100644 index 0000000..c4ad988 --- /dev/null +++ b/accounts/migrations/0003_user_notifications.py @@ -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), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 5233221..cf97a5b 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -16,6 +16,24 @@ class User(AbstractUser): jellyfin_user_id = models.CharField(max_length=100, 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) + + # 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): """Check if user is Jellyfin admin on the server""" diff --git a/accounts/templates/accounts/profile.html b/accounts/templates/accounts/profile.html index d693c61..c7fdc85 100644 --- a/accounts/templates/accounts/profile.html +++ b/accounts/templates/accounts/profile.html @@ -18,13 +18,26 @@ {% endif %}
-

Email address

+

Notifications

{% csrf_token %}
{{ form.email }}
+
+ + {{ form.notification_channel }} +
Email, ntfy, or Apprise
+
+
+ + {{ form.ntfy_topic }} +
+
+ + {{ form.apprise_url }} +
diff --git a/accounts/views.py b/accounts/views.py index 8ab32b1..fd26c62 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -26,7 +26,7 @@ def profile(request): form = CustomUserChangeForm(request.POST, instance=request.user) if form.is_valid(): form.save() - messages.success(request, 'Email saved.') + messages.success(request, 'Profile saved.') return redirect('accounts:profile') else: form = CustomUserChangeForm(instance=request.user) diff --git a/arr_api/notifications.py b/arr_api/notifications.py index 9fa7a47..5092913 100644 --- a/arr_api/notifications.py +++ b/arr_api/notifications.py @@ -2,7 +2,6 @@ from django.core.mail import send_mail from django.conf import settings from django.template.loader import render_to_string from django.utils import timezone -from django.db import transaction from settingspanel.models import AppSettings # from accounts.utils import JellyfinClient # not needed for availability; use Sonarr/Radarr instead import requests @@ -69,7 +68,7 @@ def send_notification_email( 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() logger.info( @@ -95,7 +94,7 @@ def send_notification_email( context = { 'username': user.username, 'title': media_title, - 'type': 'Series' if media_type == 'series' else 'Movie', + 'type': 'Serie' if media_type == 'series' else 'Film', 'overview': overview, 'poster_url': poster_url, 'episode_title': episode_title, @@ -106,17 +105,89 @@ def send_notification_email( '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) - send_mail( - subject=subject, - message=message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - html_message=message, - fail_silently=False, - ) + # 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( + 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(): @@ -210,8 +281,8 @@ def get_todays_radarr_calendar(): def check_jellyfin_availability(user, media_id, media_type): """ - Replaced: We check availability via Sonarr/Radarr (hasFile), - which is reliable if Jellyfin scans the same folders. + Ersetzt: Wir prüfen Verfügbarkeit über Sonarr/Radarr (hasFile), + was zuverlässig ist, wenn Jellyfin dieselben Ordner scannt. """ # user is unused here; kept for backward compatibility if media_type == 'series': @@ -255,56 +326,51 @@ def check_and_notify_users(): if season is None or number is None: 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 if sonarr_episode_has_file(sub.series_id, season, number): if not sub.user.email: continue - # After confirming availability, reserve once per user/series/day - if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): - try: - with transaction.atomic(): - obj, created = SentNotification.objects.get_or_create( - user=sub.user, - media_id=sub.series_id, - media_type='series', - air_date=today, - defaults={ - 'media_title': sub.series_title, - } - ) - if not created: - # already reserved/sent - continue - except Exception: - # if DB error (race), skip to avoid duplicates - continue - + # Build subject/body + 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: - 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'), - ) + ctx = { + 'username': sub.user.username, + 'title': sub.series_title, + '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: - # 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: - pass - continue - # no-op: already reserved via get_or_create above + pass + _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html) + # 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 for sub in MovieSubscription.objects.select_related('user').all(): @@ -312,6 +378,16 @@ def check_and_notify_users(): if not it: 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 not sub.user.email: continue @@ -329,47 +405,33 @@ def check_and_notify_users(): except Exception: pass - # After confirming availability, reserve once per user/movie/day - if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): - try: - with transaction.atomic(): - obj, created = SentNotification.objects.get_or_create( - user=sub.user, - media_id=sub.movie_id, - media_type='movie', - air_date=today, - defaults={ - 'media_title': sub.title, - } - ) - if not created: - continue - except Exception: - continue - + subj = f"New movie available: {sub.title}" + if rel: + subj += f" ({rel})" + body = f"{sub.title} is now available." + html = None 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, - ) + ctx = { + '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: - 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: - pass - continue - # no-op: already reserved via get_or_create above + pass + _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html) + 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): diff --git a/data/db.sqlite3 b/data/db.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..0f4019acb7ecf80257a80343dea2915719680514 GIT binary patch literal 180224 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 cmN0m;4a(kdExL7bo`p)Yy{{ mail_form.mail_from }}
+
+

Notifications

+

ntfy

+
{{ notify_form.ntfy_server_url }}
+
{{ notify_form.ntfy_topic_default }}
+
{{ notify_form.ntfy_user }}
+
{{ notify_form.ntfy_password }}
+
{{ notify_form.ntfy_token }}
+ +

Apprise

+
{{ notify_form.apprise_default_url }}
+
Users can also set their own ntfy topic or Apprise URLs in their profile.
+
+

Account

{{ account_form.username }}
diff --git a/settingspanel/views.py b/settingspanel/views.py index d2dfd07..9409f86 100644 --- a/settingspanel/views.py +++ b/settingspanel/views.py @@ -2,7 +2,7 @@ from django.shortcuts import render, redirect from django.views import View from django.contrib import messages 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 django.http import JsonResponse from accounts.utils import jellyfin_admin_required @@ -96,6 +96,14 @@ class SettingsView(View): "mail_password": cfg.mail_password 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={ "username": cfg.acc_username or "", "email": cfg.acc_email or "", @@ -106,13 +114,15 @@ class SettingsView(View): jellyfin_form = JellyfinSettingsForm(request.POST) arr_form = ArrSettingsForm(request.POST) mail_form = MailSettingsForm(request.POST) + notify_form = NotificationSettingsForm(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, { "jellyfin_form": jellyfin_form, "arr_form": arr_form, "mail_form": mail_form, + "notify_form": notify_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_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 cfg.acc_username = acc_form.cleaned_data.get("username") or None cfg.acc_email = acc_form.cleaned_data.get("email") or None