fix E-Mail notifications

This commit is contained in:
2025-08-16 22:15:19 +02:00
parent 00d7fe60d5
commit 2a62f37b8c
3 changed files with 114 additions and 108 deletions

View File

@@ -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}"))

View File

@@ -7,6 +7,7 @@ from settingspanel.models import AppSettings
import requests import requests
from dateutil.parser import isoparse from dateutil.parser import isoparse
import logging import logging
from django.db import transaction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -177,6 +178,8 @@ def _dispatch_user_notification(user, subject: str, body_text: str, html_message
return True return True
# fallback to email # fallback to email
try: try:
# Ensure email backend is configured from AppSettings at runtime
_set_runtime_email_settings()
send_mail( send_mail(
subject=subject, subject=subject,
message=body_text, message=body_text,
@@ -326,21 +329,10 @@ 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) # duplicate guard will be handled atomically before dispatch
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:
continue
# Build subject/body # Build subject/body
subj = f"New episode available: {sub.series_title} S{season:02d}E{number:02d}" 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." 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) html = render_to_string('arr_api/email/new_media_notification.html', ctx)
except Exception: except Exception:
pass pass
ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html) # Reserve duplicate token atomically, then dispatch; rollback on failure
# 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): try:
SentNotification.objects.create( with transaction.atomic():
token, created = SentNotification.objects.get_or_create(
user=sub.user, user=sub.user,
media_id=sub.series_id, media_id=sub.series_id,
media_type='series', media_type='series',
media_title=sub.series_title, air_date=today,
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)
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 # Film-Abos
for sub in MovieSubscription.objects.select_related('user').all(): for sub in MovieSubscription.objects.select_related('user').all():
it = movie_idx.get(sub.movie_id) 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: 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:
continue
# detect which release matched today # detect which release matched today
rel = None rel = None
try: try:
@@ -423,15 +427,32 @@ def check_and_notify_users():
html = render_to_string('arr_api/email/new_media_notification.html', ctx) html = render_to_string('arr_api/email/new_media_notification.html', ctx)
except Exception: except Exception:
pass pass
ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html) # Reserve duplicate token atomically, then dispatch; rollback on failure
if ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
SentNotification.objects.create( try:
with transaction.atomic():
token, created = SentNotification.objects.get_or_create(
user=sub.user, user=sub.user,
media_id=sub.movie_id, media_id=sub.movie_id,
media_type='movie', media_type='movie',
media_title=sub.title, air_date=today,
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 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): def has_new_episode_today(series_id):

View File

@@ -81,9 +81,13 @@ class ArrIndexView(View):
eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()] 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()] movies = [m for m in movies if q in (m.get("title") or "").lower()]
# Abonnierte Serien und Filme laden # Abonnierte Serien und Filme pro aktuellem Nutzer
subscribed_series_ids = set(SeriesSubscription.objects.values_list('series_id', flat=True)) if request.user.is_authenticated:
subscribed_movie_ids = set(MovieSubscription.objects.values_list('movie_id', flat=True)) 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 # Gruppierung nach Serie
groups = defaultdict(lambda: { groups = defaultdict(lambda: {
@@ -197,70 +201,7 @@ class CalendarEventsApi(APIView):
return Response({"events": events}) 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 @require_POST