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