From 2a62f37b8c404600879a4b4fd8db1d7e315d343a Mon Sep 17 00:00:00 2001 From: jschaufuss Date: Sat, 16 Aug 2025 22:15:19 +0200 Subject: [PATCH] fix E-Mail notifications --- .../management/commands/send_test_email.py | 44 ++++++++ arr_api/notifications.py | 103 +++++++++++------- arr_api/views.py | 75 ++----------- 3 files changed, 114 insertions(+), 108 deletions(-) create mode 100644 arr_api/management/commands/send_test_email.py diff --git a/arr_api/management/commands/send_test_email.py b/arr_api/management/commands/send_test_email.py new file mode 100644 index 0000000..e100c05 --- /dev/null +++ b/arr_api/management/commands/send_test_email.py @@ -0,0 +1,44 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from django.utils import timezone + +from arr_api.notifications import send_notification_email + + +class Command(BaseCommand): + help = "Send a test notification email to verify SMTP configuration" + + def add_arguments(self, parser): + parser.add_argument('--to', required=True, help='Recipient email address') + parser.add_argument('--username', default='testuser', help='Username to associate with the email') + parser.add_argument('--type', default='movie', choices=['movie', 'series'], help='Media type for the template') + parser.add_argument('--title', default='Subscribarr Test', help='Title to show in the email') + + def handle(self, *args, **opts): + User = get_user_model() + email = opts['to'] + username = opts['username'] + media_type = opts['type'] + title = opts['title'] + + user, _ = User.objects.get_or_create(username=username, defaults={'email': email}) + if user.email != email: + user.email = email + user.save(update_fields=['email']) + + # Use current time as air_date for nicer formatting + send_notification_email( + user=user, + media_title=title, + media_type=media_type, + overview='This is a test email from Subscribarr to verify your mail settings.', + poster_url=None, + episode_title='Pilot' if media_type == 'series' else None, + season=1 if media_type == 'series' else None, + episode=1 if media_type == 'series' else None, + air_date=timezone.now(), + year=timezone.now().year if media_type == 'movie' else None, + release_type='Test' + ) + + self.stdout.write(self.style.SUCCESS(f"Test email queued/sent to {email}")) diff --git a/arr_api/notifications.py b/arr_api/notifications.py index 4c4a5cf..172ab17 100644 --- a/arr_api/notifications.py +++ b/arr_api/notifications.py @@ -7,6 +7,7 @@ from settingspanel.models import AppSettings import requests from dateutil.parser import isoparse import logging +from django.db import transaction logger = logging.getLogger(__name__) @@ -177,6 +178,8 @@ def _dispatch_user_notification(user, subject: str, body_text: str, html_message return True # fallback to email try: + # Ensure email backend is configured from AppSettings at runtime + _set_runtime_email_settings() send_mail( subject=subject, message=body_text, @@ -326,21 +329,10 @@ 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 + # duplicate guard will be handled atomically before dispatch # check availability via Sonarr hasFile if sonarr_episode_has_file(sub.series_id, season, number): - if not sub.user.email: - 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." @@ -361,36 +353,48 @@ def check_and_notify_users(): html = render_to_string('arr_api/email/new_media_notification.html', ctx) except Exception: pass + # Reserve duplicate token atomically, then dispatch; rollback on failure + if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): + try: + with transaction.atomic(): + token, 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: + continue + except Exception: + # race or DB error -> skip to avoid duplicates + continue ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html) - # mark as sent unless duplicates are allowed - if ok and 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 - ) + if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): + # allow retry on next run + try: + SentNotification.objects.filter( + user=sub.user, + media_id=sub.series_id, + media_type='series', + air_date=today + ).delete() + except Exception: + pass # Film-Abos for sub in MovieSubscription.objects.select_related('user').all(): it = movie_idx.get(sub.movie_id) + # Fallback: if movie_id missing, try match by title + if not it and getattr(sub, 'title', None): + for _mid, _it in movie_idx.items(): + if (_it.get('title') or '').strip().lower() == (sub.title or '').strip().lower(): + it = _it + break if not it: 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 # detect which release matched today rel = None try: @@ -423,15 +427,32 @@ def check_and_notify_users(): html = render_to_string('arr_api/email/new_media_notification.html', ctx) except Exception: pass + # Reserve duplicate token atomically, then dispatch; rollback on failure + if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): + try: + with transaction.atomic(): + token, 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 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( - user=sub.user, - media_id=sub.movie_id, - media_type='movie', - media_title=sub.title, - air_date=today - ) + if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): + try: + SentNotification.objects.filter( + user=sub.user, + media_id=sub.movie_id, + media_type='movie', + air_date=today + ).delete() + except Exception: + pass def has_new_episode_today(series_id): diff --git a/arr_api/views.py b/arr_api/views.py index d026ec1..d4c0c3a 100644 --- a/arr_api/views.py +++ b/arr_api/views.py @@ -81,9 +81,13 @@ class ArrIndexView(View): eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()] movies = [m for m in movies if q in (m.get("title") or "").lower()] - # Abonnierte Serien und Filme laden - subscribed_series_ids = set(SeriesSubscription.objects.values_list('series_id', flat=True)) - subscribed_movie_ids = set(MovieSubscription.objects.values_list('movie_id', flat=True)) + # Abonnierte Serien und Filme pro aktuellem Nutzer + if request.user.is_authenticated: + subscribed_series_ids = set(SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True)) + subscribed_movie_ids = set(MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True)) + else: + subscribed_series_ids = set() + subscribed_movie_ids = set() # Gruppierung nach Serie groups = defaultdict(lambda: { @@ -197,70 +201,7 @@ class CalendarEventsApi(APIView): return Response({"events": events}) -class SubscribeSeriesView(View): - @method_decorator(require_POST) - def post(self, request, series_id): - series_data = { - 'series_id': series_id, - 'series_title': request.POST.get('series_title'), - 'series_poster': request.POST.get('series_poster'), - 'series_overview': request.POST.get('series_overview'), - 'series_genres': request.POST.getlist('series_genres[]', []) - } - - subscription, created = SeriesSubscription.objects.get_or_create( - series_id=series_id, - defaults=series_data - ) - - if created: - messages.success(request, f'Subscribed to series "{series_data["series_title"]}"!') - else: - messages.info(request, f'Series "{series_data["series_title"]}" was already subscribed.') - - return redirect('arr_api:index') - -class UnsubscribeSeriesView(View): - @method_decorator(require_POST) - def post(self, request, series_id): - subscription = get_object_or_404(SeriesSubscription, series_id=series_id) - series_title = subscription.series_title - subscription.delete() - messages.success(request, f'Subscription for "{series_title}" has been removed.') - return redirect('arr_api:index') - -class SubscribeMovieView(View): - @method_decorator(require_POST) - def post(self, request, movie_id): - movie_data = { - 'movie_id': movie_id, - 'title': request.POST.get('title'), - 'poster': request.POST.get('poster'), - 'overview': request.POST.get('overview'), - 'genres': request.POST.getlist('genres[]', []), - 'release_date': request.POST.get('release_date') - } - - subscription, created = MovieSubscription.objects.get_or_create( - movie_id=movie_id, - defaults=movie_data - ) - - if created: - messages.success(request, f'Subscribed to movie "{movie_data["title"]}"!') - else: - messages.info(request, f'Movie "{movie_data["title"]}" was already subscribed.') - - return redirect('arr_api:index') - -class UnsubscribeMovieView(View): - @method_decorator(require_POST) - def post(self, request, movie_id): - subscription = get_object_or_404(MovieSubscription, movie_id=movie_id) - movie_title = subscription.title - subscription.delete() - messages.success(request, f'Subscription for "{movie_title}" has been removed.') - return redirect('arr_api:index') + @require_POST