added Support for ntfy and apprise
This commit is contained in:
		| @@ -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): | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 jschaufuss@leitwerk.de
					jschaufuss@leitwerk.de