added Support for ntfy and apprise

This commit is contained in:
jschaufuss@leitwerk.de
2025-08-15 13:02:19 +02:00
parent 839fafdb33
commit defbe0dc9c
14 changed files with 443 additions and 103 deletions

View File

@@ -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):