multiuser/subscriptions/notifications
This commit is contained in:
		
							
								
								
									
										14
									
								
								arr_api/management/commands/check_new_media.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								arr_api/management/commands/check_new_media.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.utils import timezone | ||||
| from arr_api.notifications import check_and_notify_users | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = 'Prüft neue Medien und sendet Benachrichtigungen' | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         self.stdout.write(f'[{timezone.now()}] Starte Medien-Check...') | ||||
|         try: | ||||
|             check_and_notify_users() | ||||
|             self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Medien-Check erfolgreich beendet')) | ||||
|         except Exception as e: | ||||
|             self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Fehler beim Medien-Check: {str(e)}')) | ||||
							
								
								
									
										52
									
								
								arr_api/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								arr_api/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| # Generated by Django 5.2.5 on 2025-08-10 11:59 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='MovieSubscription', | ||||
|             fields=[ | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('movie_id', models.IntegerField()), | ||||
|                 ('title', models.CharField(max_length=255)), | ||||
|                 ('poster', models.URLField(blank=True, null=True)), | ||||
|                 ('overview', models.TextField(blank=True)), | ||||
|                 ('genres', models.JSONField(default=list)), | ||||
|                 ('release_date', models.DateTimeField(null=True)), | ||||
|                 ('created_at', models.DateTimeField(auto_now_add=True)), | ||||
|                 ('updated_at', models.DateTimeField(auto_now=True)), | ||||
|                 ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movie_subscriptions', to=settings.AUTH_USER_MODEL)), | ||||
|             ], | ||||
|             options={ | ||||
|                 'unique_together': {('user', 'movie_id')}, | ||||
|             }, | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='SeriesSubscription', | ||||
|             fields=[ | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('series_id', models.IntegerField()), | ||||
|                 ('series_title', models.CharField(max_length=255)), | ||||
|                 ('series_poster', models.URLField(blank=True, null=True)), | ||||
|                 ('series_overview', models.TextField(blank=True)), | ||||
|                 ('series_genres', models.JSONField(default=list)), | ||||
|                 ('created_at', models.DateTimeField(auto_now_add=True)), | ||||
|                 ('updated_at', models.DateTimeField(auto_now=True)), | ||||
|                 ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series_subscriptions', to=settings.AUTH_USER_MODEL)), | ||||
|             ], | ||||
|             options={ | ||||
|                 'unique_together': {('user', 'series_id')}, | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										32
									
								
								arr_api/migrations/0002_sentnotification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								arr_api/migrations/0002_sentnotification.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| # Generated by Django 5.2.5 on 2025-08-10 14:44 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('arr_api', '0001_initial'), | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='SentNotification', | ||||
|             fields=[ | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('media_id', models.IntegerField()), | ||||
|                 ('media_type', models.CharField(max_length=10)), | ||||
|                 ('media_title', models.CharField(max_length=255)), | ||||
|                 ('air_date', models.DateField()), | ||||
|                 ('sent_at', models.DateTimeField(auto_now_add=True)), | ||||
|                 ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), | ||||
|             ], | ||||
|             options={ | ||||
|                 'ordering': ['-sent_at'], | ||||
|                 'unique_together': {('user', 'media_id', 'media_type', 'air_date')}, | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,3 +1,53 @@ | ||||
| from django.db import models | ||||
| from django.conf import settings | ||||
|  | ||||
| # Create your models here. | ||||
| class SeriesSubscription(models.Model): | ||||
|     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='series_subscriptions') | ||||
|     series_id = models.IntegerField() | ||||
|     series_title = models.CharField(max_length=255) | ||||
|     series_poster = models.URLField(null=True, blank=True) | ||||
|     series_overview = models.TextField(blank=True) | ||||
|     series_genres = models.JSONField(default=list) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|     updated_at = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = ['user', 'series_id']  # Ein User kann eine Serie nur einmal abonnieren | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.series_title | ||||
|  | ||||
| class MovieSubscription(models.Model): | ||||
|     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='movie_subscriptions') | ||||
|     movie_id = models.IntegerField() | ||||
|     title = models.CharField(max_length=255) | ||||
|     poster = models.URLField(null=True, blank=True) | ||||
|     overview = models.TextField(blank=True) | ||||
|     genres = models.JSONField(default=list) | ||||
|     release_date = models.DateTimeField(null=True) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|     updated_at = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = ['user', 'movie_id']  # Ein User kann einen Film nur einmal abonnieren | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.title | ||||
|  | ||||
| class SentNotification(models.Model): | ||||
|     """ | ||||
|     Speichert gesendete Benachrichtigungen um Duplikate zu vermeiden | ||||
|     """ | ||||
|     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) | ||||
|     media_id = models.IntegerField() | ||||
|     media_type = models.CharField(max_length=10)  # 'series' oder 'movie' | ||||
|     media_title = models.CharField(max_length=255) | ||||
|     air_date = models.DateField() | ||||
|     sent_at = models.DateTimeField(auto_now_add=True) | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = ['user', 'media_id', 'media_type', 'air_date'] | ||||
|         ordering = ['-sent_at'] | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"{self.media_type}: {self.media_title} for {self.user.username} on {self.air_date}" | ||||
|   | ||||
							
								
								
									
										356
									
								
								arr_api/notifications.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										356
									
								
								arr_api/notifications.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,356 @@ | ||||
| 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 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, | ||||
| ): | ||||
|     """ | ||||
|     Sendet eine Benachrichtigungs-E-Mail an einen User mit erweiterten 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': 'Serie' if media_type == 'series' else 'Film', | ||||
|         '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"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, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| 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): | ||||
|     """ | ||||
|     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': | ||||
|         # 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 | ||||
|  | ||||
|             # 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 | ||||
|                 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'), | ||||
|                 ) | ||||
|                 # 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(): | ||||
|         it = movie_idx.get(sub.movie_id) | ||||
|         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: | ||||
|                 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 | ||||
|  | ||||
|             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, | ||||
|             ) | ||||
|             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): | ||||
|     """ | ||||
|     Legacy helper no longer used directly. | ||||
|     """ | ||||
|     return True | ||||
|  | ||||
|  | ||||
| def has_movie_release_today(movie_id): | ||||
|     """ | ||||
|     Legacy helper no longer used directly. | ||||
|     """ | ||||
|     return True | ||||
| @@ -25,7 +25,7 @@ def _get(url, headers, params=None, timeout=5): | ||||
| def _abs_url(base: str, p: str | None) -> str | None: | ||||
|     if not p: | ||||
|         return None | ||||
|     return f"{base.rstrip('/')}{p}" if p.startswith("/") else p | ||||
|     return f"{base.rstrip('/')}" + p if p.startswith("/") else p | ||||
|  | ||||
| def sonarr_calendar(days: int | None = None, base_url: str | None = None, api_key: str | None = None): | ||||
|     base = (base_url or ENV_SONARR_URL).strip() | ||||
| @@ -131,3 +131,62 @@ def radarr_calendar(days: int | None = None, base_url: str | None = None, api_ke | ||||
|         return False | ||||
|  | ||||
|     return [m for m in out if is_upcoming(m)] | ||||
|  | ||||
| def sonarr_get_series(series_id: int, base_url: str | None = None, api_key: str | None = None) -> dict | None: | ||||
|     """Fetch a single series by id from Sonarr, return dict with title, overview, poster and genres.""" | ||||
|     base = (base_url or ENV_SONARR_URL).strip() | ||||
|     key  = (api_key  or ENV_SONARR_KEY).strip() | ||||
|     if not base or not key: | ||||
|         return None | ||||
|     url = f"{base.rstrip('/')}/api/v3/series/{series_id}" | ||||
|     headers = {"X-Api-Key": key} | ||||
|     data = _get(url, headers) | ||||
|     # Poster | ||||
|     poster = None | ||||
|     for img in (data.get("images") or []): | ||||
|         if (img.get("coverType") or "").lower() == "poster": | ||||
|             poster = img.get("remoteUrl") or _abs_url(base, img.get("url")) | ||||
|             if poster: | ||||
|                 break | ||||
|     return { | ||||
|         "series_id": data.get("id"), | ||||
|         "series_title": data.get("title"), | ||||
|         "series_overview": data.get("overview") or "", | ||||
|         "series_genres": data.get("genres") or [], | ||||
|         "series_poster": poster, | ||||
|     } | ||||
|  | ||||
| def radarr_lookup_movie_by_title(title: str, base_url: str | None = None, api_key: str | None = None) -> dict | None: | ||||
|     """Lookup a movie by title via Radarr /api/v3/movie/lookup. Returns title, poster, overview, genres, year, tmdbId, and id if present.""" | ||||
|     base = (base_url or ENV_RADARR_URL).strip() | ||||
|     key  = (api_key  or ENV_RADARR_KEY).strip() | ||||
|     if not base or not key or not title: | ||||
|         return None | ||||
|     url = f"{base.rstrip('/')}/api/v3/movie/lookup" | ||||
|     headers = {"X-Api-Key": key} | ||||
|     data = _get(url, headers, params={"term": title}) | ||||
|     if not data: | ||||
|         return None | ||||
|     # naive pick: exact match by title (case-insensitive), else first | ||||
|     best = None | ||||
|     for it in data: | ||||
|         if (it.get("title") or "").lower() == title.lower(): | ||||
|             best = it | ||||
|             break | ||||
|     if not best: | ||||
|         best = data[0] | ||||
|     poster = None | ||||
|     for img in (best.get("images") or []): | ||||
|         if (img.get("coverType") or "").lower() == "poster": | ||||
|             poster = img.get("remoteUrl") or _abs_url(base, img.get("url")) | ||||
|             if poster: | ||||
|                 break | ||||
|     return { | ||||
|         "movie_id": best.get("id") or 0, | ||||
|         "title": best.get("title") or title, | ||||
|         "poster": poster, | ||||
|         "overview": best.get("overview") or "", | ||||
|         "genres": best.get("genres") or [], | ||||
|         "year": best.get("year"), | ||||
|         "tmdbId": best.get("tmdbId"), | ||||
|     } | ||||
|   | ||||
							
								
								
									
										111
									
								
								arr_api/templates/arr_api/email/new_media_notification.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								arr_api/templates/arr_api/email/new_media_notification.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|  | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <style> | ||||
|         body { | ||||
|             font-family: Arial, sans-serif; | ||||
|             line-height: 1.6; | ||||
|             color: #333; | ||||
|             max-width: 700px; | ||||
|             margin: 0 auto; | ||||
|             padding: 20px; | ||||
|         } | ||||
|  | ||||
|         .header { | ||||
|             border-bottom: 2px solid #3b82f6; | ||||
|             padding-bottom: 10px; | ||||
|             margin-bottom: 20px; | ||||
|         } | ||||
|  | ||||
|         .content { | ||||
|             background: #f8fafc; | ||||
|             padding: 20px; | ||||
|             border-radius: 8px; | ||||
|             display: grid; | ||||
|             grid-template-columns: 140px 1fr; | ||||
|             gap: 16px; | ||||
|             align-items: start; | ||||
|         } | ||||
|  | ||||
|         .title { | ||||
|             color: #1e40af; | ||||
|             font-size: 22px; | ||||
|             margin: 0 0 8px 0; | ||||
|         } | ||||
|  | ||||
|         .overview { | ||||
|             color: #444; | ||||
|             margin: 10px 0; | ||||
|         } | ||||
|  | ||||
|         .meta { | ||||
|             color: #555; | ||||
|             font-size: 14px; | ||||
|             margin: 6px 0; | ||||
|         } | ||||
|  | ||||
|         .poster { | ||||
|             width: 140px; | ||||
|             border-radius: 6px; | ||||
|             background: #e5e7eb; | ||||
|             object-fit: cover; | ||||
|         } | ||||
|  | ||||
|         .kbd { | ||||
|             display: inline-block; | ||||
|             padding: 2px 6px; | ||||
|             border: 1px solid #d1d5db; | ||||
|             border-bottom-width: 2px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             background: #fff; | ||||
|         } | ||||
|     </style> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|     <div class="header"> | ||||
|         <h1>Neue {{ type }} verfügbar!</h1> | ||||
|     </div> | ||||
|     <div class="content"> | ||||
|         {% if poster_url %} | ||||
|         <img class="poster" src="{{ poster_url }}" alt="Poster" /> | ||||
|         {% else %} | ||||
|         <div></div> | ||||
|         {% endif %} | ||||
|  | ||||
|         <div> | ||||
|             <p>Hallo {{ username }},</p> | ||||
|  | ||||
|             <h2 class="title">{{ title }}</h2> | ||||
|  | ||||
|             {% if episode_title %} | ||||
|             <p class="meta">Episode: <strong>{{ episode_title }}</strong></p> | ||||
|             {% endif %} | ||||
|             {% if season and episode %} | ||||
|             <p class="meta"><span class="kbd">S{{ season }}E{{ episode }}</span></p> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if year %} | ||||
|             <p class="meta">Jahr: <strong>{{ year }}</strong></p> | ||||
|             {% endif %} | ||||
|             {% if release_type %} | ||||
|             <p class="meta">Release: {{ release_type }}</p> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if air_date %} | ||||
|             <p class="meta">Veröffentlicht am: {{ air_date }}</p> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if overview %} | ||||
|             <p class="overview">{{ overview }}</p> | ||||
|             {% endif %} | ||||
|  | ||||
|             <p>Du kannst das jetzt auf Jellyfin anschauen.</p> | ||||
|         </div> | ||||
|     </div> | ||||
| </body> | ||||
|  | ||||
| </html> | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,7 +1,25 @@ | ||||
| from django.urls import path | ||||
| from .views import SonarrAiringView, RadarrUpcomingMoviesView | ||||
| from .views import ( | ||||
|     ArrIndexView, SeriesSubscribeView, SeriesUnsubscribeView, | ||||
|     MovieSubscribeView, MovieUnsubscribeView, | ||||
|     ListSeriesSubscriptionsView, ListMovieSubscriptionsView | ||||
| ) | ||||
|  | ||||
| app_name = 'arr_api' | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("sonarr/airing", SonarrAiringView.as_view(), name="sonarr-airing"), | ||||
|     path("radarr/upcoming", RadarrUpcomingMoviesView.as_view(), name="radarr-upcoming"), | ||||
|     path('', ArrIndexView.as_view(), name='index'), | ||||
|      | ||||
|     # Series URLs | ||||
|     path('api/series/subscribe/<int:series_id>/', SeriesSubscribeView.as_view(), name='subscribe-series'), | ||||
|     path('api/series/unsubscribe/<int:series_id>/', SeriesUnsubscribeView.as_view(), name='unsubscribe-series'), | ||||
|     path('api/series/subscriptions/', ListSeriesSubscriptionsView.as_view(), name='list-series-subscriptions'), | ||||
|      | ||||
|     # Movie URLs | ||||
|     path('api/movies/subscribe/<str:title>/', MovieSubscribeView.as_view(), name='subscribe-movie'), | ||||
|     path('api/movies/unsubscribe/<str:title>/', MovieUnsubscribeView.as_view(), name='unsubscribe-movie'), | ||||
|     path('api/movies/subscriptions/', ListMovieSubscriptionsView.as_view(), name='list-movie-subscriptions'), | ||||
|      | ||||
|     # Get all subscriptions | ||||
|  | ||||
| ] | ||||
							
								
								
									
										305
									
								
								arr_api/views.py
									
									
									
									
									
								
							
							
						
						
									
										305
									
								
								arr_api/views.py
									
									
									
									
									
								
							| @@ -1,13 +1,18 @@ | ||||
| from collections import defaultdict | ||||
| from django.shortcuts import render | ||||
| from django.shortcuts import render, redirect, get_object_or_404 | ||||
| from django.views import View | ||||
| from django.contrib import messages | ||||
| from django.views.decorators.http import require_POST | ||||
| from django.utils.decorators import method_decorator | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.http import JsonResponse | ||||
| from rest_framework.views import APIView | ||||
| from rest_framework.response import Response | ||||
| from rest_framework import status | ||||
|  | ||||
| from settingspanel.models import AppSettings | ||||
| from .services import sonarr_calendar, radarr_calendar, ArrServiceError | ||||
| from .models import SeriesSubscription, MovieSubscription | ||||
|  | ||||
|  | ||||
| def _get_int(request, key, default): | ||||
| @@ -27,26 +32,26 @@ def _arr_conf_from_db(): | ||||
|     } | ||||
|  | ||||
|  | ||||
| class SonarrAiringView(APIView): | ||||
|     def get(self, request): | ||||
|         days = _get_int(request, "days", 30) | ||||
|         conf = _arr_conf_from_db() | ||||
|         try: | ||||
|             data = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"]) | ||||
|             return Response({"count": len(data), "results": data}) | ||||
|         except ArrServiceError as e: | ||||
|             return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | ||||
| #class SonarrAiringView(APIView): | ||||
| #    def get(self, request): | ||||
| #        days = _get_int(request, "days", 30) | ||||
| #        conf = _arr_conf_from_db() | ||||
| #        try: | ||||
| #            data = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"]) | ||||
| #            return Response({"count": len(data), "results": data}) | ||||
| #        except ArrServiceError as e: | ||||
| #            return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | ||||
|  | ||||
|  | ||||
| class RadarrUpcomingMoviesView(APIView): | ||||
|     def get(self, request): | ||||
|         days = _get_int(request, "days", 60) | ||||
|         conf = _arr_conf_from_db() | ||||
|         try: | ||||
|             data = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"]) | ||||
|             return Response({"count": len(data), "results": data}) | ||||
|         except ArrServiceError as e: | ||||
|             return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | ||||
| #class RadarrUpcomingMoviesView(APIView): | ||||
| #    def get(self, request): | ||||
| #        days = _get_int(request, "days", 60) | ||||
| #        conf = _arr_conf_from_db() | ||||
| #        try: | ||||
| #            data = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"]) | ||||
| #            return Response({"count": len(data), "results": data}) | ||||
| #        except ArrServiceError as e: | ||||
| #            return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | ||||
|  | ||||
|  | ||||
| class ArrIndexView(View): | ||||
| @@ -75,10 +80,14 @@ 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)) | ||||
|  | ||||
|         # Gruppierung nach Serie | ||||
|         groups = defaultdict(lambda: { | ||||
|             "seriesId": None, "seriesTitle": None, "seriesPoster": None, | ||||
|             "seriesOverview": "", "seriesGenres": [], "episodes": [], | ||||
|             "seriesOverview": "", "seriesGenres": [], "episodes": [], "is_subscribed": False, | ||||
|         }) | ||||
|         for e in eps: | ||||
|             sid = e["seriesId"] | ||||
| @@ -101,8 +110,13 @@ class ArrIndexView(View): | ||||
|         series_grouped = [] | ||||
|         for g in groups.values(): | ||||
|             g["episodes"].sort(key=lambda x: (x["airDateUtc"] or "")) | ||||
|             g["is_subscribed"] = g["seriesId"] in subscribed_series_ids | ||||
|             series_grouped.append(g) | ||||
|  | ||||
|         # Markiere abonnierte Filme | ||||
|         for movie in movies: | ||||
|             movie["is_subscribed"] = movie.get("movieId") in subscribed_movie_ids | ||||
|  | ||||
|         return render(request, "arr_api/index.html", { | ||||
|             "query": q, | ||||
|             "kind": kind, | ||||
| @@ -111,4 +125,253 @@ class ArrIndexView(View): | ||||
|             "show_movies": kind in ("all", "movies"), | ||||
|             "series_grouped": series_grouped, | ||||
|             "movies": movies, | ||||
|         }) | ||||
|         }) | ||||
|  | ||||
|  | ||||
| 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'Serie "{series_data["series_title"]}" wurde abonniert!') | ||||
|         else: | ||||
|             messages.info(request, f'Serie "{series_data["series_title"]}" war bereits abonniert.') | ||||
|              | ||||
|         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'Abonnement für "{series_title}" wurde beendet.') | ||||
|         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'Film "{movie_data["title"]}" wurde abonniert!') | ||||
|         else: | ||||
|             messages.info(request, f'Film "{movie_data["title"]}" war bereits abonniert.') | ||||
|              | ||||
|         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'Abonnement für "{movie_title}" wurde beendet.') | ||||
|         return redirect('arr_api:index') | ||||
|  | ||||
|  | ||||
| @require_POST | ||||
| @login_required | ||||
| def subscribe_series(request, series_id): | ||||
|     """Serie abonnieren""" | ||||
|     try: | ||||
|         # Existiert bereits? | ||||
|         if SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists(): | ||||
|             return JsonResponse({'success': True, 'already_subscribed': True}) | ||||
|  | ||||
|         # Hole Serien-Details vom Sonarr | ||||
|         conf = _arr_conf_from_db() | ||||
|         # TODO: Sonarr API Call für Series Details | ||||
|  | ||||
|         # Erstelle Subscription | ||||
|         sub = SeriesSubscription.objects.create( | ||||
|             user=request.user, | ||||
|             series_id=series_id, | ||||
|             series_title=request.POST.get('title', ''), | ||||
|             series_poster=request.POST.get('poster', ''), | ||||
|             series_overview=request.POST.get('overview', ''), | ||||
|             series_genres=request.POST.getlist('genres[]', []) | ||||
|         ) | ||||
|         return JsonResponse({'success': True}) | ||||
|  | ||||
|     except Exception as e: | ||||
|         return JsonResponse({'error': str(e)}, status=400) | ||||
|  | ||||
| @require_POST | ||||
| @login_required | ||||
| def unsubscribe_series(request, series_id): | ||||
|     """Serie deabonnieren""" | ||||
|     try: | ||||
|         SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete() | ||||
|         return JsonResponse({'success': True}) | ||||
|     except Exception as e: | ||||
|         return JsonResponse({'error': str(e)}, status=400) | ||||
|  | ||||
| @login_required | ||||
| def is_subscribed_series(request, series_id): | ||||
|     """Prüfe ob Serie abonniert ist""" | ||||
|     is_subbed = SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists() | ||||
|     return JsonResponse({'subscribed': is_subbed}) | ||||
|  | ||||
| @require_POST | ||||
| @login_required | ||||
| def subscribe_movie(request, movie_id): | ||||
|     """Film abonnieren""" | ||||
|     try: | ||||
|         # Existiert bereits? | ||||
|         if MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists(): | ||||
|             return JsonResponse({'success': True, 'already_subscribed': True}) | ||||
|  | ||||
|         # Hole Film-Details vom Radarr | ||||
|         conf = _arr_conf_from_db() | ||||
|         # TODO: Radarr API Call für Movie Details | ||||
|  | ||||
|         # Erstelle Subscription | ||||
|         sub = MovieSubscription.objects.create( | ||||
|             user=request.user, | ||||
|             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') | ||||
|         ) | ||||
|         return JsonResponse({'success': True}) | ||||
|  | ||||
|     except Exception as e: | ||||
|         return JsonResponse({'error': str(e)}, status=400) | ||||
|  | ||||
| @require_POST | ||||
| @login_required | ||||
| def unsubscribe_movie(request, movie_id): | ||||
|     """Film deabonnieren""" | ||||
|     try: | ||||
|         MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).delete() | ||||
|         return JsonResponse({'success': True}) | ||||
|     except Exception as e: | ||||
|         return JsonResponse({'error': str(e)}, status=400) | ||||
|  | ||||
| @login_required | ||||
| def is_subscribed_movie(request, movie_id): | ||||
|     """Prüfe ob Film abonniert ist""" | ||||
|     is_subbed = MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists() | ||||
|     return JsonResponse({'subscribed': is_subbed}) | ||||
|  | ||||
| @login_required | ||||
| def get_subscriptions(request): | ||||
|     """Hole alle Abonnements des Users""" | ||||
|     series = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True) | ||||
|     movies = MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True) | ||||
|     return JsonResponse({ | ||||
|         'series': list(series), | ||||
|         'movies': list(movies) | ||||
|     }) | ||||
|  | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class SeriesSubscribeView(APIView): | ||||
|     def post(self, request, series_id): | ||||
|         from .services import sonarr_get_series | ||||
|         cfg = AppSettings.current() | ||||
|         details = None | ||||
|         try: | ||||
|             details = sonarr_get_series(series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key) | ||||
|         except Exception: | ||||
|             details = None | ||||
|         defaults = { | ||||
|             'series_title': request.data.get('title', '') if request.data else '', | ||||
|             'series_poster': request.data.get('poster', '') if request.data else '', | ||||
|             'series_overview': request.data.get('overview', '') if request.data else '', | ||||
|             'series_genres': request.data.get('genres', []) if request.data else [], | ||||
|         } | ||||
|         if details: | ||||
|             defaults.update({ | ||||
|                 'series_title': details.get('series_title') or defaults['series_title'], | ||||
|                 'series_poster': details.get('series_poster') or defaults['series_poster'], | ||||
|                 'series_overview': details.get('series_overview') or defaults['series_overview'], | ||||
|                 'series_genres': details.get('series_genres') or defaults['series_genres'], | ||||
|             }) | ||||
|         sub, created = SeriesSubscription.objects.get_or_create( | ||||
|             user=request.user, | ||||
|             series_id=series_id, | ||||
|             defaults=defaults | ||||
|         ) | ||||
|         return Response({'status': 'subscribed'}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK) | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class SeriesUnsubscribeView(APIView): | ||||
|     def post(self, request, series_id): | ||||
|         SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete() | ||||
|         return Response({'status': 'unsubscribed'}, status=status.HTTP_200_OK) | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class MovieSubscribeView(APIView): | ||||
|     def post(self, request, title): | ||||
|         from .services import radarr_lookup_movie_by_title | ||||
|         cfg = AppSettings.current() | ||||
|         details = None | ||||
|         try: | ||||
|             details = radarr_lookup_movie_by_title(title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key) | ||||
|         except Exception: | ||||
|             details = None | ||||
|         defaults = { | ||||
|             'movie_id': (request.data.get('movie_id', 0) if request.data else 0) or 0, | ||||
|             'poster': request.data.get('poster', '') if request.data else '', | ||||
|             'overview': request.data.get('overview', '') if request.data else '', | ||||
|             'genres': request.data.get('genres', []) if request.data else [], | ||||
|         } | ||||
|         if details: | ||||
|             defaults.update({ | ||||
|                 'movie_id': details.get('movie_id') or defaults['movie_id'], | ||||
|                 'poster': details.get('poster') or defaults['poster'], | ||||
|                 'overview': details.get('overview') or defaults['overview'], | ||||
|                 'genres': details.get('genres') or defaults['genres'], | ||||
|             }) | ||||
|         sub, created = MovieSubscription.objects.get_or_create( | ||||
|             user=request.user, | ||||
|             title=title, | ||||
|             defaults=defaults | ||||
|         ) | ||||
|         return Response({'status': 'subscribed'}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK) | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class MovieUnsubscribeView(APIView): | ||||
|     def post(self, request, title): | ||||
|         MovieSubscription.objects.filter(user=request.user, title=title).delete() | ||||
|         return Response({'status': 'unsubscribed'}, status=status.HTTP_200_OK) | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class ListSeriesSubscriptionsView(APIView): | ||||
|     def get(self, request): | ||||
|         subs = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True) | ||||
|         return Response(list(subs)) | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class ListMovieSubscriptionsView(APIView): | ||||
|     def get(self, request): | ||||
|         subs = MovieSubscription.objects.filter(user=request.user).values_list('title', flat=True) | ||||
|         return Response(list(subs)) | ||||
		Reference in New Issue
	
	Block a user