Files
Subscribarr/arr_api/notifications.py
2025-08-13 21:13:25 +02:00

387 lines
13 KiB
Python

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
from dateutil.parser import isoparse
import logging
logger = logging.getLogger(__name__)
def _set_runtime_email_settings():
app_settings = AppSettings.current()
sec = (app_settings.mail_secure or '').strip().lower()
use_tls = sec in ('tls', 'starttls', 'start_tls', 'tls1.2', 'tls1_2')
use_ssl = sec in ('ssl', 'smtps')
# Prefer SSL over TLS if both matched somehow
if use_ssl:
use_tls = False
# Apply email settings dynamically for this process
settings.EMAIL_HOST = (app_settings.mail_host or settings.EMAIL_HOST)
# Port defaults if not provided
if app_settings.mail_port:
settings.EMAIL_PORT = int(app_settings.mail_port)
else:
if use_ssl and not settings.EMAIL_PORT:
settings.EMAIL_PORT = 465
elif use_tls and not settings.EMAIL_PORT:
settings.EMAIL_PORT = 587
settings.EMAIL_USE_TLS = use_tls
settings.EMAIL_USE_SSL = use_ssl
settings.EMAIL_HOST_USER = app_settings.mail_user or settings.EMAIL_HOST_USER
settings.EMAIL_HOST_PASSWORD = app_settings.mail_password or settings.EMAIL_HOST_PASSWORD
# From email fallback
if app_settings.mail_from:
settings.DEFAULT_FROM_EMAIL = app_settings.mail_from
elif not getattr(settings, 'DEFAULT_FROM_EMAIL', None):
host = (settings.EMAIL_HOST or 'localhost')
settings.DEFAULT_FROM_EMAIL = f'noreply@{host}'
# return summary for debugging
return {
'host': settings.EMAIL_HOST,
'port': settings.EMAIL_PORT,
'use_tls': settings.EMAIL_USE_TLS,
'use_ssl': settings.EMAIL_USE_SSL,
'from_email': settings.DEFAULT_FROM_EMAIL,
'auth_user_set': bool(settings.EMAIL_HOST_USER),
}
def send_notification_email(
user,
media_title,
media_type,
overview=None,
poster_url=None,
episode_title=None,
season=None,
episode=None,
air_date=None,
year=None,
release_type=None,
):
"""
Sends a notification email to a user with extended details
"""
eff = _set_runtime_email_settings()
logger.info(
"Email settings: host=%s port=%s tls=%s ssl=%s from=%s auth_user_set=%s",
eff['host'], eff['port'], eff['use_tls'], eff['use_ssl'], eff['from_email'], eff['auth_user_set']
)
# Format air date if provided
air_date_str = None
if air_date:
try:
from dateutil.parser import isoparse as _iso
dt = _iso(air_date) if isinstance(air_date, str) else air_date
try:
tz = timezone.get_current_timezone()
dt = dt.astimezone(tz)
except Exception:
pass
air_date_str = dt.strftime('%d.%m.%Y %H:%M')
except Exception:
air_date_str = str(air_date)
context = {
'username': user.username,
'title': media_title,
'type': 'Series' if media_type == 'series' else 'Movie',
'overview': overview,
'poster_url': poster_url,
'episode_title': episode_title,
'season': season,
'episode': episode,
'air_date': air_date_str,
'year': year,
'release_type': release_type,
}
subject = f"New {context['type']} available: {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,
)
def _get_arr_cfg():
cfg = AppSettings.current()
return {
'sonarr_url': (cfg.sonarr_url or '').strip(),
'sonarr_key': (cfg.sonarr_api_key or '').strip(),
'radarr_url': (cfg.radarr_url or '').strip(),
'radarr_key': (cfg.radarr_api_key or '').strip(),
}
def _sonarr_get(url_base, api_key, path, params=None, timeout=10):
if not url_base or not api_key:
return None
url = f"{url_base.rstrip('/')}{path}"
try:
r = requests.get(url, headers={"X-Api-Key": api_key}, params=params or {}, timeout=timeout)
r.raise_for_status()
return r.json()
except requests.RequestException:
return None
def _radarr_get(url_base, api_key, path, params=None, timeout=10):
if not url_base or not api_key:
return None
url = f"{url_base.rstrip('/')}{path}"
try:
r = requests.get(url, headers={"X-Api-Key": api_key}, params=params or {}, timeout=timeout)
r.raise_for_status()
return r.json()
except requests.RequestException:
return None
def sonarr_episode_has_file(series_id: int, season: int, episode: int) -> bool:
cfg = _get_arr_cfg()
data = _sonarr_get(cfg['sonarr_url'], cfg['sonarr_key'], "/api/v3/episode", params={"seriesId": series_id}) or []
for ep in data:
if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode:
return bool(ep.get("hasFile"))
return False
def radarr_movie_has_file(movie_id: int) -> bool:
cfg = _get_arr_cfg()
data = _radarr_get(cfg['radarr_url'], cfg['radarr_key'], f"/api/v3/movie/{movie_id}")
if not data:
return False
return bool(data.get("hasFile"))
def get_todays_sonarr_calendar():
from .services import sonarr_calendar
cfg = _get_arr_cfg()
items = sonarr_calendar(days=1, base_url=cfg['sonarr_url'], api_key=cfg['sonarr_key']) or []
today = timezone.now().date()
todays = []
for it in items:
try:
ad = isoparse(it.get("airDateUtc")) if it.get("airDateUtc") else None
if ad and ad.date() == today:
todays.append(it)
except Exception:
pass
return todays
def get_todays_radarr_calendar():
from .services import radarr_calendar
cfg = _get_arr_cfg()
items = radarr_calendar(days=1, base_url=cfg['radarr_url'], api_key=cfg['radarr_key']) or []
today = timezone.now().date()
todays = []
for it in items:
# consider any of the dates equal today
for k in ("inCinemas", "physicalRelease", "digitalRelease"):
v = it.get(k)
if not v:
continue
try:
d = isoparse(v).date()
if d == today:
todays.append(it)
break
except Exception:
continue
return todays
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.
"""
# user is unused here; kept for backward compatibility
if media_type == 'series':
# cannot decide without season/episode here; will be handled in main loop
return False
else:
return radarr_movie_has_file(media_id)
def check_and_notify_users():
"""
Hauptfunktion die periodisch aufgerufen wird.
Prüft neue Medien und sendet Benachrichtigungen.
"""
from .models import SeriesSubscription, MovieSubscription, SentNotification
# calendars for today
todays_series = get_todays_sonarr_calendar()
todays_movies = get_todays_radarr_calendar()
# index by ids for quick lookup
series_idx = {}
for it in todays_series:
sid = it.get("seriesId")
if not sid:
continue
series_idx.setdefault(sid, []).append(it)
movie_idx = {it.get("movieId"): it for it in todays_movies if it.get("movieId")}
today = timezone.now().date()
# Serien-Abos
for sub in SeriesSubscription.objects.select_related('user').all():
if sub.series_id not in series_idx:
continue
# iterate today's episodes for this series
for ep in series_idx[sub.series_id]:
season = ep.get("seasonNumber")
number = ep.get("episodeNumber")
if season is None or number is None:
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
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'),
)
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
# Film-Abos
for sub in MovieSubscription.objects.select_related('user').all():
it = movie_idx.get(sub.movie_id)
if not it:
continue
if radarr_movie_has_file(sub.movie_id):
if not sub.user.email:
continue
# detect which release matched today
rel = None
try:
for key, name in (("digitalRelease", "Digital"), ("physicalRelease", "Disc"), ("inCinemas", "Kino")):
v = it.get(key)
if not v:
continue
d = isoparse(v).date()
if d == today:
rel = name
break
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
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,
)
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
def has_new_episode_today(series_id):
"""
Legacy helper no longer used directly.
"""
return True
def has_movie_release_today(movie_id):
"""
Legacy helper no longer used directly.
"""
return True