fix E-Mail notifications
This commit is contained in:
44
arr_api/management/commands/send_test_email.py
Normal file
44
arr_api/management/commands/send_test_email.py
Normal 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}"))
|
@@ -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):
|
||||||
|
@@ -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
|
||||||
|
Reference in New Issue
Block a user