usermanagement/translation/calendar
This commit is contained in:
@@ -10,15 +10,15 @@ class CustomUserCreationForm(UserCreationForm):
|
|||||||
fields = ('username', 'email', 'password1', 'password2')
|
fields = ('username', 'email', 'password1', 'password2')
|
||||||
|
|
||||||
class CustomUserChangeForm(UserChangeForm):
|
class CustomUserChangeForm(UserChangeForm):
|
||||||
password = None # Passwort-Änderung über extra Formular
|
password = None # Password change via separate form
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ('email',)
|
fields = ('email',)
|
||||||
widgets = {
|
widgets = {
|
||||||
'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'E-Mail-Adresse'}),
|
'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'Email address'}),
|
||||||
}
|
}
|
||||||
|
|
||||||
class JellyfinLoginForm(forms.Form):
|
class JellyfinLoginForm(forms.Form):
|
||||||
username = forms.CharField(label='Benutzername', widget=forms.TextInput(attrs={'class': 'form-control'}))
|
username = forms.CharField(label='Username', widget=forms.TextInput(attrs={'class': 'form-control'}))
|
||||||
password = forms.CharField(label='Passwort', widget=forms.PasswordInput(attrs={'class': 'form-control'}))
|
password = forms.CharField(label='Password', widget=forms.PasswordInput(attrs={'class': 'form-control'}))
|
||||||
|
@@ -4,9 +4,9 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
"""
|
"""
|
||||||
Custom User Model mit zusätzlichen Feldern und Berechtigungen.
|
Custom User Model with additional fields and permissions.
|
||||||
Normale User können nur ihre eigenen Daten bearbeiten.
|
Regular users can only edit their own data.
|
||||||
Admin-User können alles.
|
Admin users can edit everything.
|
||||||
"""
|
"""
|
||||||
email = models.EmailField(_("email address"), unique=True)
|
email = models.EmailField(_("email address"), unique=True)
|
||||||
bio = models.TextField(max_length=500, blank=True)
|
bio = models.TextField(max_length=500, blank=True)
|
||||||
@@ -26,7 +26,7 @@ class User(AbstractUser):
|
|||||||
client = JellyfinClient()
|
client = JellyfinClient()
|
||||||
return client.is_admin(self.jellyfin_user_id, self.jellyfin_token)
|
return client.is_admin(self.jellyfin_user_id, self.jellyfin_token)
|
||||||
except:
|
except:
|
||||||
# Im Fehlerfall den lokalen Status verwenden
|
# On error, fall back to local status
|
||||||
return self.is_admin
|
return self.is_admin
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<h2>Anmelden</h2>
|
<h2>Sign in</h2>
|
||||||
<form method="post" class="auth-form">
|
<form method="post" class="auth-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{{ form.as_p }}
|
||||||
<button type="submit" class="btn-primary">Anmelden</button>
|
<button type="submit" class="btn-primary">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="auth-links">
|
<div class="auth-links">
|
||||||
<p>Noch kein Konto? <a href="{% url 'accounts:register' %}">Jetzt registrieren</a></p>
|
<p>Don't have an account? <a href="{% url 'accounts:register' %}">Register now</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<h2>Passwort ändern</h2>
|
<h2>Change password</h2>
|
||||||
<form method="post" class="auth-form">
|
<form method="post" class="auth-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{{ form.as_p }}
|
||||||
<button type="submit" class="btn-primary">Passwort ändern</button>
|
<button type="submit" class="btn-primary">Change password</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<h2>Passwort geändert</h2>
|
<h2>Password changed</h2>
|
||||||
<p>Ihr Passwort wurde erfolgreich geändert.</p>
|
<p>Your password has been changed successfully.</p>
|
||||||
<p><a href="{% url 'accounts:profile' %}">Zurück zum Profil</a></p>
|
<p><a href="{% url 'accounts:profile' %}">Back to profile</a></p>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="profile-container">
|
<div class="profile-container">
|
||||||
<h2>Hallo, {{ user.username }}</h2>
|
<h2>Hello, {{ user.username }}</h2>
|
||||||
|
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="messages">
|
<div class="messages">
|
||||||
@@ -18,24 +18,24 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
<h3>E-Mail-Adresse</h3>
|
<h3>Email address</h3>
|
||||||
<form method="post" class="profile-form compact-form">
|
<form method="post" class="profile-form compact-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="id_email">E-Mail</label>
|
<label for="id_email">Email</label>
|
||||||
{{ form.email }}
|
{{ form.email }}
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary">Speichern</button>
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if user.jellyfin_server %}
|
{% if user.jellyfin_server %}
|
||||||
<div class="jellyfin-info">
|
<div class="jellyfin-info">
|
||||||
<h4>Jellyfin-Verbindung</h4>
|
<h4>Jellyfin connection</h4>
|
||||||
<p>
|
<p>
|
||||||
Server: {{ user.jellyfin_server }}<br>
|
Server: {{ user.jellyfin_server }}<br>
|
||||||
Status: {% if user.jellyfin_token %}Verbunden{% else %}Nicht verbunden{% endif %}<br>
|
Status: {% if user.jellyfin_token %}Connected{% else %}Not connected{% endif %}<br>
|
||||||
{% if user.is_jellyfin_admin %}
|
{% if user.is_jellyfin_admin %}
|
||||||
<span class="badge badge-admin">Jellyfin Administrator</span>
|
<span class="badge badge-admin">Jellyfin administrator</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,9 +43,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
<h3>Meine Abonnements</h3>
|
<h3>My subscriptions</h3>
|
||||||
|
|
||||||
<h4>Serien</h4>
|
<h4>Series</h4>
|
||||||
{% if series_subs %}
|
{% if series_subs %}
|
||||||
<div class="subscription-list">
|
<div class="subscription-list">
|
||||||
{% for sub in series_subs %}
|
{% for sub in series_subs %}
|
||||||
@@ -53,11 +53,11 @@
|
|||||||
{% if sub.series_poster %}
|
{% if sub.series_poster %}
|
||||||
<img src="{{ sub.series_poster }}" alt="{{ sub.series_title }}" class="subscription-poster">
|
<img src="{{ sub.series_poster }}" alt="{{ sub.series_title }}" class="subscription-poster">
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster">
|
<img src="https://via.placeholder.com/80x120?text=No+Poster" alt="" class="subscription-poster">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="subscription-info">
|
<div class="subscription-info">
|
||||||
<div class="subscription-title">{{ sub.series_title }}</div>
|
<div class="subscription-title">{{ sub.series_title }}</div>
|
||||||
<div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div>
|
<div class="subscription-date">Subscribed on {{ sub.created_at|date:"d.m.Y" }}</div>
|
||||||
{% if sub.series_overview %}
|
{% if sub.series_overview %}
|
||||||
<div class="subscription-overview">{{ sub.series_overview|truncatechars:100 }}</div>
|
<div class="subscription-overview">{{ sub.series_overview|truncatechars:100 }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -66,10 +66,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Keine Serien abonniert.</p>
|
<p class="muted">No series subscribed.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h4>Filme</h4>
|
<h4>Movies</h4>
|
||||||
{% if movie_subs %}
|
{% if movie_subs %}
|
||||||
<div class="subscription-list">
|
<div class="subscription-list">
|
||||||
{% for sub in movie_subs %}
|
{% for sub in movie_subs %}
|
||||||
@@ -77,11 +77,11 @@
|
|||||||
{% if sub.poster %}
|
{% if sub.poster %}
|
||||||
<img src="{{ sub.poster }}" alt="{{ sub.title }}" class="subscription-poster">
|
<img src="{{ sub.poster }}" alt="{{ sub.title }}" class="subscription-poster">
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster">
|
<img src="https://via.placeholder.com/80x120?text=No+Poster" alt="" class="subscription-poster">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="subscription-info">
|
<div class="subscription-info">
|
||||||
<div class="subscription-title">{{ sub.title }}</div>
|
<div class="subscription-title">{{ sub.title }}</div>
|
||||||
<div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div>
|
<div class="subscription-date">Subscribed on {{ sub.created_at|date:"d.m.Y" }}</div>
|
||||||
{% if sub.overview %}
|
{% if sub.overview %}
|
||||||
<div class="subscription-overview">{{ sub.overview|truncatechars:100 }}</div>
|
<div class="subscription-overview">{{ sub.overview|truncatechars:100 }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Keine Filme abonniert.</p>
|
<p class="muted">No movies subscribed.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<h2>Registrieren</h2>
|
<h2>Register</h2>
|
||||||
<form method="post" class="auth-form">
|
<form method="post" class="auth-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{{ form.as_p }}
|
||||||
<button type="submit" class="btn-primary">Registrieren</button>
|
<button type="submit" class="btn-primary">Register</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="auth-links">
|
<div class="auth-links">
|
||||||
<p>Bereits ein Konto? <a href="{% url 'accounts:login' %}">Jetzt anmelden</a></p>
|
<p>Already have an account? <a href="{% url 'accounts:login' %}">Sign in</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@@ -7,7 +7,7 @@ from django.contrib import messages
|
|||||||
|
|
||||||
class JellyfinClient:
|
class JellyfinClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Basis-Einstellungen aus den Django-Settings
|
# Base settings from Django settings
|
||||||
self.client = settings.JELLYFIN_CLIENT
|
self.client = settings.JELLYFIN_CLIENT
|
||||||
self.version = settings.JELLYFIN_VERSION
|
self.version = settings.JELLYFIN_VERSION
|
||||||
self.device = settings.JELLYFIN_DEVICE
|
self.device = settings.JELLYFIN_DEVICE
|
||||||
@@ -18,13 +18,13 @@ class JellyfinClient:
|
|||||||
def authenticate(self, username, password):
|
def authenticate(self, username, password):
|
||||||
"""Authenticate with Jellyfin and return user info if successful"""
|
"""Authenticate with Jellyfin and return user info if successful"""
|
||||||
if not self.server_url:
|
if not self.server_url:
|
||||||
raise ValueError("Keine Server-URL angegeben")
|
raise ValueError("No server URL provided")
|
||||||
|
|
||||||
# Stelle sicher, dass die URL ein Protokoll hat
|
# Ensure the URL has a protocol
|
||||||
if not self.server_url.startswith(('http://', 'https://')):
|
if not self.server_url.startswith(('http://', 'https://')):
|
||||||
self.server_url = f'http://{self.server_url}'
|
self.server_url = f'http://{self.server_url}'
|
||||||
|
|
||||||
# Entferne trailing slashes
|
# Remove trailing slashes
|
||||||
self.server_url = self.server_url.rstrip('/')
|
self.server_url = self.server_url.rstrip('/')
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
@@ -57,13 +57,13 @@ class JellyfinClient:
|
|||||||
'is_admin': data['User'].get('Policy', {}).get('IsAdministrator', False)
|
'is_admin': data['User'].get('Policy', {}).get('IsAdministrator', False)
|
||||||
}
|
}
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
raise ValueError("Verbindung zum Server nicht möglich. Bitte überprüfen Sie die Server-URL.")
|
raise ValueError("Unable to connect to the server. Please check the server URL.")
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
raise ValueError("Zeitüberschreitung bei der Verbindung zum Server.")
|
raise ValueError("Connection to the server timed out.")
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
if e.response.status_code == 401:
|
if e.response.status_code == 401:
|
||||||
return None # Authentifizierung fehlgeschlagen
|
return None # Authentifizierung fehlgeschlagen
|
||||||
raise ValueError(f"HTTP-Fehler: {e.response.status_code}")
|
raise ValueError(f"HTTP error: {e.response.status_code}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class JellyfinClient:
|
|||||||
"""Check if user is admin in Jellyfin"""
|
"""Check if user is admin in Jellyfin"""
|
||||||
cache_key = f'jellyfin_admin_{user_id}'
|
cache_key = f'jellyfin_admin_{user_id}'
|
||||||
|
|
||||||
# Check cache first
|
# Check cache first
|
||||||
cached = cache.get(cache_key)
|
cached = cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
@@ -106,11 +106,11 @@ def jellyfin_admin_required(view_func):
|
|||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def _wrapped_view(request, *args, **kwargs):
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
messages.error(request, 'Sie müssen angemeldet sein, um diese Seite zu sehen.')
|
messages.error(request, 'You must be logged in to view this page.')
|
||||||
return redirect('accounts:login')
|
return redirect('accounts:login')
|
||||||
|
|
||||||
if not request.user.is_jellyfin_admin:
|
if not request.user.is_jellyfin_admin:
|
||||||
messages.error(request, 'Sie benötigen Admin-Rechte, um diese Seite zu sehen.')
|
messages.error(request, 'You need admin rights to view this page.')
|
||||||
return redirect('index')
|
return redirect('index')
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
@@ -17,7 +17,7 @@ class RegisterView(CreateView):
|
|||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
messages.success(self.request, 'Registrierung erfolgreich! Sie können sich jetzt anmelden.')
|
messages.success(self.request, 'Registration successful! You can now sign in.')
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -26,12 +26,12 @@ def profile(request):
|
|||||||
form = CustomUserChangeForm(request.POST, instance=request.user)
|
form = CustomUserChangeForm(request.POST, instance=request.user)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
messages.success(request, 'E-Mail gespeichert.')
|
messages.success(request, 'Email saved.')
|
||||||
return redirect('accounts:profile')
|
return redirect('accounts:profile')
|
||||||
else:
|
else:
|
||||||
form = CustomUserChangeForm(instance=request.user)
|
form = CustomUserChangeForm(instance=request.user)
|
||||||
|
|
||||||
# Lade Abonnements
|
# Load subscriptions
|
||||||
series_subs = request.user.series_subscriptions.all()
|
series_subs = request.user.series_subscriptions.all()
|
||||||
movie_subs = request.user.movie_subscriptions.all()
|
movie_subs = request.user.movie_subscriptions.all()
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ def profile(request):
|
|||||||
from settingspanel.models import AppSettings
|
from settingspanel.models import AppSettings
|
||||||
from arr_api.services import sonarr_get_series, radarr_lookup_movie_by_title
|
from arr_api.services import sonarr_get_series, radarr_lookup_movie_by_title
|
||||||
cfg = AppSettings.current()
|
cfg = AppSettings.current()
|
||||||
# Serien
|
# Series
|
||||||
for sub in series_subs:
|
for sub in series_subs:
|
||||||
if not sub.series_poster and sub.series_id:
|
if not sub.series_poster and sub.series_id:
|
||||||
details = sonarr_get_series(sub.series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key)
|
details = sonarr_get_series(sub.series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key)
|
||||||
@@ -51,7 +51,7 @@ def profile(request):
|
|||||||
if not sub.series_genres:
|
if not sub.series_genres:
|
||||||
sub.series_genres = details.get('series_genres') or []
|
sub.series_genres = details.get('series_genres') or []
|
||||||
sub.save(update_fields=['series_poster', 'series_overview', 'series_genres'])
|
sub.save(update_fields=['series_poster', 'series_overview', 'series_genres'])
|
||||||
# Filme
|
# Movies
|
||||||
for sub in movie_subs:
|
for sub in movie_subs:
|
||||||
if not sub.poster:
|
if not sub.poster:
|
||||||
details = radarr_lookup_movie_by_title(sub.title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key)
|
details = radarr_lookup_movie_by_title(sub.title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key)
|
||||||
@@ -84,7 +84,7 @@ def jellyfin_login(request):
|
|||||||
app_settings = AppSettings.current()
|
app_settings = AppSettings.current()
|
||||||
server_url = app_settings.get_jellyfin_url()
|
server_url = app_settings.get_jellyfin_url()
|
||||||
if not server_url:
|
if not server_url:
|
||||||
messages.error(request, 'Jellyfin Server ist nicht konfiguriert. Bitte Setup abschließen.')
|
messages.error(request, 'Jellyfin server is not configured. Please complete setup.')
|
||||||
return render(request, 'accounts/login.html', {'form': form})
|
return render(request, 'accounts/login.html', {'form': form})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -93,7 +93,7 @@ def jellyfin_login(request):
|
|||||||
auth_result = client.authenticate(username, password)
|
auth_result = client.authenticate(username, password)
|
||||||
|
|
||||||
if not auth_result:
|
if not auth_result:
|
||||||
messages.error(request, 'Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.')
|
messages.error(request, 'Sign in failed. Please check your credentials.')
|
||||||
return render(request, 'accounts/login.html', {'form': form})
|
return render(request, 'accounts/login.html', {'form': form})
|
||||||
|
|
||||||
# Existierenden User finden oder neu erstellen
|
# Existierenden User finden oder neu erstellen
|
||||||
@@ -116,13 +116,13 @@ def jellyfin_login(request):
|
|||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
login(request, user)
|
login(request, user)
|
||||||
messages.success(request, f'Willkommen, {username}!')
|
messages.success(request, f'Welcome, {username}!')
|
||||||
return redirect('arr_api:index')
|
return redirect('arr_api:index')
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
messages.error(request, str(e))
|
messages.error(request, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, f'Verbindungsfehler: {str(e)}')
|
messages.error(request, f'Connection error: {str(e)}')
|
||||||
# invalid form or error path
|
# invalid form or error path
|
||||||
return render(request, 'accounts/login.html', {'form': form})
|
return render(request, 'accounts/login.html', {'form': form})
|
||||||
|
|
||||||
|
@@ -3,12 +3,12 @@ from django.utils import timezone
|
|||||||
from arr_api.notifications import check_and_notify_users
|
from arr_api.notifications import check_and_notify_users
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Prüft neue Medien und sendet Benachrichtigungen'
|
help = 'Checks for new media and sends notifications'
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
self.stdout.write(f'[{timezone.now()}] Starte Medien-Check...')
|
self.stdout.write(f'[{timezone.now()}] Starting media check...')
|
||||||
try:
|
try:
|
||||||
check_and_notify_users()
|
check_and_notify_users()
|
||||||
self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Medien-Check erfolgreich beendet'))
|
self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Media check finished successfully'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Fehler beim Medien-Check: {str(e)}'))
|
self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Error during media check: {str(e)}'))
|
||||||
|
@@ -12,7 +12,7 @@ class SeriesSubscription(models.Model):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ['user', 'series_id'] # Ein User kann eine Serie nur einmal abonnieren
|
unique_together = ['user', 'series_id'] # A user can subscribe to a series only once
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.series_title
|
return self.series_title
|
||||||
@@ -29,18 +29,16 @@ class MovieSubscription(models.Model):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ['user', 'movie_id'] # Ein User kann einen Film nur einmal abonnieren
|
unique_together = ['user', 'movie_id'] # A user can subscribe to a movie only once
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
class SentNotification(models.Model):
|
class SentNotification(models.Model):
|
||||||
"""
|
"""Store sent notifications to avoid duplicates"""
|
||||||
Speichert gesendete Benachrichtigungen um Duplikate zu vermeiden
|
|
||||||
"""
|
|
||||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
media_id = models.IntegerField()
|
media_id = models.IntegerField()
|
||||||
media_type = models.CharField(max_length=10) # 'series' oder 'movie'
|
media_type = models.CharField(max_length=10) # 'series' or 'movie'
|
||||||
media_title = models.CharField(max_length=255)
|
media_title = models.CharField(max_length=255)
|
||||||
air_date = models.DateField()
|
air_date = models.DateField()
|
||||||
sent_at = models.DateTimeField(auto_now_add=True)
|
sent_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
@@ -68,7 +68,7 @@ def send_notification_email(
|
|||||||
release_type=None,
|
release_type=None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Sendet eine Benachrichtigungs-E-Mail an einen User mit erweiterten Details
|
Sends a notification email to a user with extended details
|
||||||
"""
|
"""
|
||||||
eff = _set_runtime_email_settings()
|
eff = _set_runtime_email_settings()
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -94,7 +94,7 @@ def send_notification_email(
|
|||||||
context = {
|
context = {
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
'title': media_title,
|
'title': media_title,
|
||||||
'type': 'Serie' if media_type == 'series' else 'Film',
|
'type': 'Series' if media_type == 'series' else 'Movie',
|
||||||
'overview': overview,
|
'overview': overview,
|
||||||
'poster_url': poster_url,
|
'poster_url': poster_url,
|
||||||
'episode_title': episode_title,
|
'episode_title': episode_title,
|
||||||
@@ -105,7 +105,7 @@ def send_notification_email(
|
|||||||
'release_type': release_type,
|
'release_type': release_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
subject = f"Neue {context['type']} verfügbar: {media_title}"
|
subject = f"New {context['type']} available: {media_title}"
|
||||||
message = render_to_string('arr_api/email/new_media_notification.html', context)
|
message = render_to_string('arr_api/email/new_media_notification.html', context)
|
||||||
|
|
||||||
send_mail(
|
send_mail(
|
||||||
@@ -209,8 +209,8 @@ def get_todays_radarr_calendar():
|
|||||||
|
|
||||||
def check_jellyfin_availability(user, media_id, media_type):
|
def check_jellyfin_availability(user, media_id, media_type):
|
||||||
"""
|
"""
|
||||||
Ersetzt: Wir prüfen Verfügbarkeit über Sonarr/Radarr (hasFile),
|
Replaced: We check availability via Sonarr/Radarr (hasFile),
|
||||||
was zuverlässig ist, wenn Jellyfin dieselben Ordner scannt.
|
which is reliable if Jellyfin scans the same folders.
|
||||||
"""
|
"""
|
||||||
# user is unused here; kept for backward compatibility
|
# user is unused here; kept for backward compatibility
|
||||||
if media_type == 'series':
|
if media_type == 'series':
|
||||||
|
301
arr_api/templates/arr_api/calendar.html
Normal file
301
arr_api/templates/arr_api/calendar.html
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Calendar – Subscribarr{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_style %}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #3fb950; /* Subscribarr green */
|
||||||
|
--bg-soft: #0f172a;
|
||||||
|
--card: #0b1224;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--border: #1f2a44;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 1200px; margin: 0 auto; padding: 12px; }
|
||||||
|
h1 { margin: 10px 0 18px; font-size: 22px; }
|
||||||
|
|
||||||
|
/* FullCalendar theme tweaks */
|
||||||
|
.fc { --fc-border-color: var(--border); color: var(--text); }
|
||||||
|
.fc-theme-standard { --fc-page-bg-color: var(--card); --fc-neutral-bg-color: #0f172a; }
|
||||||
|
.fc .fc-scrollgrid, .fc .fc-scrollgrid-section > td { background: var(--card); }
|
||||||
|
.fc .fc-col-header, .fc .fc-col-header-cell { background: #0f172a; }
|
||||||
|
.fc .fc-daygrid-day, .fc .fc-timegrid-slot { background: transparent; }
|
||||||
|
.fc .fc-toolbar { gap: 8px; margin-bottom: 12px; display: flex; flex-wrap: wrap; }
|
||||||
|
.fc .fc-toolbar-title { font-size: 18px; }
|
||||||
|
.fc .fc-button { background: #121b33; border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 8px; }
|
||||||
|
.fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||||
|
.fc .fc-button-primary:not(:disabled):active { background: #1a2542; border-color: var(--border); }
|
||||||
|
.fc .fc-button:hover { filter: brightness(1.1); }
|
||||||
|
.fc .fc-button:focus { box-shadow: 0 0 0 2px rgba(63,185,80,.35); }
|
||||||
|
.fc .fc-daygrid-day-number { color: var(--muted); }
|
||||||
|
.fc .fc-day-today { background: rgba(63,185,80,0.08); }
|
||||||
|
.fc .fc-col-header-cell-cushion { color: var(--muted); }
|
||||||
|
|
||||||
|
.fc .fc-daygrid-event, .fc .fc-timegrid-event, .fc .fc-list-event { cursor: pointer; }
|
||||||
|
.fc .fc-daygrid-event { border-radius: 6px; padding: 2px 4px; border: 1px solid var(--border); }
|
||||||
|
.fc .subscribed-event { border-left: 4px solid var(--primary) !important; background: rgba(63,185,80,0.06); }
|
||||||
|
.fc .event-series { border-left-color: #60a5fa; }
|
||||||
|
.fc .event-movie { border-left-color: #f59e0b; }
|
||||||
|
.fc .fc-list {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.fc .fc-list-event:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
.event-poster { width: 24px; height: 36px; object-fit: cover; border-radius: 2px; margin-right: 6px; }
|
||||||
|
#calendar { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 100%; }
|
||||||
|
|
||||||
|
/* Compact tweaks for phones */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.fc .fc-toolbar-title { font-size: 16px; }
|
||||||
|
.fc .fc-button { padding: 5px 8px; border-radius: 8px; }
|
||||||
|
.fc .fc-col-header-cell-cushion { font-size: 12px; }
|
||||||
|
.fc .fc-daygrid-day-number { font-size: 12px; }
|
||||||
|
.fc .fc-daygrid-event { font-size: 12px; padding: 1px 3px; }
|
||||||
|
.event-poster { width: 20px; height: 30px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal minor polish in this page */
|
||||||
|
.modal { background: #0b1224; border: 1px solid var(--border); }
|
||||||
|
.btn-subscribe { background: var(--primary); color: #06210e; }
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/index.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Calendar</h1>
|
||||||
|
<div id="calendar"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal (same as on the homepage) -->
|
||||||
|
<div id="modalBackdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-hidden="true" style="display:none">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="m-poster-wrap">
|
||||||
|
<div class="m-poster"><img id="mPoster" alt=""></div>
|
||||||
|
<button id="subscribeBtn" class="btn-subscribe" type="button">Subscribe</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div id="mTitle" class="m-title"></div>
|
||||||
|
<div id="mSub" class="m-sub"></div>
|
||||||
|
<div id="mBadges" class="badges"></div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" title="Close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="section-block">
|
||||||
|
<div class="section-title">Overview</div>
|
||||||
|
<div id="mOverview" class="desc muted"></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-block">
|
||||||
|
<div class="section-title">Upcoming episodes</div>
|
||||||
|
<div class="section-divider"></div>
|
||||||
|
<div id="mEpisodes"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% csrf_token %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const $ = (s, r=document) => r.querySelector(s);
|
||||||
|
const backdrop = $("#modalBackdrop");
|
||||||
|
const closeBtn = backdrop.querySelector(".modal-close");
|
||||||
|
const mPoster = $("#mPoster");
|
||||||
|
const mTitle = $("#mTitle");
|
||||||
|
const mOverview = $("#mOverview");
|
||||||
|
const mEpisodes = $("#mEpisodes");
|
||||||
|
const mSub = $("#mSub");
|
||||||
|
const subscribeBtn = $("#subscribeBtn");
|
||||||
|
const epSection = mEpisodes.closest(".section-block");
|
||||||
|
const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||||
|
const bc = ('BroadcastChannel' in window) ? new BroadcastChannel('subscribarr-sub') : null;
|
||||||
|
|
||||||
|
function openModal(){ backdrop.style.display = 'flex'; backdrop.setAttribute('aria-hidden','false'); document.body.style.overflow='hidden'; }
|
||||||
|
function closeModal(){ backdrop.style.display = 'none'; backdrop.setAttribute('aria-hidden','true'); document.body.style.overflow=''; }
|
||||||
|
closeBtn.addEventListener('click', closeModal);
|
||||||
|
backdrop.addEventListener('click', (e)=>{ if(e.target===backdrop) closeModal(); });
|
||||||
|
window.addEventListener('keydown', e=>{ if(e.key==='Escape') closeModal(); });
|
||||||
|
|
||||||
|
// subscription cache
|
||||||
|
const subCache = new Map();
|
||||||
|
async function loadAllSubs(){
|
||||||
|
try {
|
||||||
|
const [s,m] = await Promise.all([
|
||||||
|
fetch('/api/series/subscriptions/'),
|
||||||
|
fetch('/api/movies/subscriptions/')
|
||||||
|
]);
|
||||||
|
if(s.ok){ (await s.json()).forEach(id => subCache.set(`series:${id}`, true)); }
|
||||||
|
if(m.ok){ (await m.json()).forEach(title => subCache.set(`movie:${title}`, true)); }
|
||||||
|
} catch(err){ console.warn('subs load failed', err); }
|
||||||
|
}
|
||||||
|
function isSub(kind, idOrTitle){
|
||||||
|
const key = kind==='series' ? `series:${idOrTitle}` : `movie:${idOrTitle}`;
|
||||||
|
return subCache.has(key);
|
||||||
|
}
|
||||||
|
async function toggleSub(kind, idOrTitle, on){
|
||||||
|
const url = kind==='series'
|
||||||
|
? `/api/series/${on?'subscribe':'unsubscribe'}/${encodeURIComponent(idOrTitle)}/`
|
||||||
|
: `/api/movies/${on?'subscribe':'unsubscribe'}/${encodeURIComponent(idOrTitle)}/`;
|
||||||
|
const resp = await fetch(url, {method:'POST', headers:{'X-CSRFToken': csrf}});
|
||||||
|
if(!resp.ok){ throw new Error('HTTP '+resp.status); }
|
||||||
|
const key = kind==='series' ? `series:${idOrTitle}` : `movie:${idOrTitle}`;
|
||||||
|
if(on) subCache.set(key, true); else subCache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentEvent = null;
|
||||||
|
let calendar = null;
|
||||||
|
function showEvent(ev){
|
||||||
|
currentEvent = ev;
|
||||||
|
const p = ev.extendedProps || {};
|
||||||
|
const kind = p.kind;
|
||||||
|
const poster = p.poster || 'https://via.placeholder.com/130x195?text=No+Poster';
|
||||||
|
mPoster.src = poster;
|
||||||
|
if(kind==='series'){
|
||||||
|
mTitle.textContent = `${p.seriesTitle} — S${p.seasonNumber}E${p.episodeNumber}${p.episodeTitle?(' · '+p.episodeTitle):''}`;
|
||||||
|
mOverview.textContent = p.overview || '';
|
||||||
|
epSection.style.display = 'none';
|
||||||
|
mEpisodes.innerHTML = '';
|
||||||
|
mSub.textContent = new Date(ev.start).toLocaleString();
|
||||||
|
} else {
|
||||||
|
mTitle.textContent = p.title || ev.title;
|
||||||
|
mOverview.textContent = p.overview || '';
|
||||||
|
epSection.style.display = 'none';
|
||||||
|
mEpisodes.innerHTML = '';
|
||||||
|
mSub.textContent = new Date(ev.start).toLocaleDateString();
|
||||||
|
}
|
||||||
|
subscribeBtn.textContent = isSub(kind, kind==='series'?p.seriesId:(p.title||'')) ? 'Unsubscribe' : 'Subscribe';
|
||||||
|
openModal();
|
||||||
|
}
|
||||||
|
subscribeBtn.addEventListener('click', async ()=>{
|
||||||
|
if(!currentEvent) return;
|
||||||
|
const p = currentEvent.extendedProps || {};
|
||||||
|
const kind = p.kind;
|
||||||
|
const key = kind==='series'? p.seriesId : (p.title||'');
|
||||||
|
const newState = !isSub(kind, key);
|
||||||
|
// Optimistic UI
|
||||||
|
subscribeBtn.textContent = newState ? 'Unsubscribe' : 'Subscribe';
|
||||||
|
// Update event visuals immediately
|
||||||
|
try {
|
||||||
|
if(currentEvent){
|
||||||
|
currentEvent.setExtendedProp('subscribed', newState);
|
||||||
|
// Also tag by kind for subtle color; ensure classNames contains baseline kind
|
||||||
|
const base = [];
|
||||||
|
if((currentEvent.extendedProps||{}).kind === 'series') base.push('event-series');
|
||||||
|
if((currentEvent.extendedProps||{}).kind === 'movie') base.push('event-movie');
|
||||||
|
if(newState) base.push('subscribed-event');
|
||||||
|
currentEvent.setProp('classNames', base);
|
||||||
|
if(calendar) calendar.rerenderEvents();
|
||||||
|
}
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
|
try {
|
||||||
|
await toggleSub(kind, key, newState);
|
||||||
|
if(bc) bc.postMessage({ type:'sub_change', kind, key, on:newState });
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
// revert visual/state on failure
|
||||||
|
try {
|
||||||
|
if(currentEvent){
|
||||||
|
currentEvent.setExtendedProp('subscribed', !newState);
|
||||||
|
const base = [];
|
||||||
|
if((currentEvent.extendedProps||{}).kind === 'series') base.push('event-series');
|
||||||
|
if((currentEvent.extendedProps||{}).kind === 'movie') base.push('event-movie');
|
||||||
|
if(!newState) base.push('subscribed-event');
|
||||||
|
currentEvent.setProp('classNames', base);
|
||||||
|
if(calendar) calendar.rerenderEvents();
|
||||||
|
}
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
|
await loadAllSubs();
|
||||||
|
const calendarEl = document.getElementById('calendar');
|
||||||
|
const isCompact = () => (calendarEl?.clientWidth || window.innerWidth) < 720;
|
||||||
|
const compact = isCompact();
|
||||||
|
calendar = new FullCalendar.Calendar(calendarEl, {
|
||||||
|
initialView: compact ? 'listWeek' : 'dayGridMonth',
|
||||||
|
height: 'auto',
|
||||||
|
locale: 'en',
|
||||||
|
headerToolbar: compact
|
||||||
|
? { left:'prev,next', center:'title', right:'listWeek,dayGridMonth' }
|
||||||
|
: { left:'prev,next today', center:'title', right:'dayGridMonth,timeGridWeek,listWeek' },
|
||||||
|
buttonText: { listWeek: 'List', dayGridMonth: 'Month', timeGridWeek: 'Week', today: 'Today' },
|
||||||
|
firstDay: 1,
|
||||||
|
nowIndicator: true,
|
||||||
|
dayMaxEventRows: compact ? 2 : 5,
|
||||||
|
expandRows: true,
|
||||||
|
handleWindowResize: true,
|
||||||
|
windowResize: function(){
|
||||||
|
const c = isCompact();
|
||||||
|
const should = c ? 'listWeek' : 'dayGridMonth';
|
||||||
|
if(calendar.view.type !== should){ calendar.changeView(should); }
|
||||||
|
calendar.setOption('headerToolbar', c
|
||||||
|
? { left:'prev,next', center:'title', right:'listWeek,dayGridMonth' }
|
||||||
|
: { left:'prev,next today', center:'title', right:'dayGridMonth,timeGridWeek,listWeek' }
|
||||||
|
);
|
||||||
|
calendar.setOption('dayMaxEventRows', c ? 2 : 5);
|
||||||
|
calendar.setOption('displayEventTime', !c);
|
||||||
|
},
|
||||||
|
eventClassNames: function(arg){
|
||||||
|
const p = arg.event.extendedProps || {};
|
||||||
|
const classes = [];
|
||||||
|
if(p.kind === 'series') classes.push('event-series');
|
||||||
|
if(p.kind === 'movie') classes.push('event-movie');
|
||||||
|
if(p.subscribed) classes.push('subscribed-event');
|
||||||
|
return classes;
|
||||||
|
},
|
||||||
|
events: async (info, success, failure) => {
|
||||||
|
try {
|
||||||
|
const url = `/api/calendar/events/?days={{ days|default:60 }}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const data = await resp.json();
|
||||||
|
const evs = (data.events||[]);
|
||||||
|
success(evs);
|
||||||
|
} catch(err){ failure(err); }
|
||||||
|
},
|
||||||
|
eventClick: function(arg){ arg.jsEvent.preventDefault(); showEvent(arg.event); },
|
||||||
|
eventDidMount: function(info){
|
||||||
|
const p = info.event.extendedProps || {};
|
||||||
|
if(info.view.type.startsWith('list') && p.poster){
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = p.poster; img.className = 'event-poster';
|
||||||
|
const titleEl = info.el.querySelector('.fc-list-event-title');
|
||||||
|
if(titleEl){ titleEl.prepend(img); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
calendar.render();
|
||||||
|
// Listen for sub changes from other tabs/pages
|
||||||
|
if(bc){
|
||||||
|
bc.onmessage = (evt)=>{
|
||||||
|
const msg = evt.data || {};
|
||||||
|
if(msg.type !== 'sub_change') return;
|
||||||
|
const {kind, key, on} = msg;
|
||||||
|
try {
|
||||||
|
calendar.getEvents().forEach(ev=>{
|
||||||
|
const p = ev.extendedProps || {};
|
||||||
|
const match = (kind==='series' && String(p.seriesId)===String(key)) || (kind==='movie' && (p.title||'')===key);
|
||||||
|
if(match){
|
||||||
|
ev.setExtendedProp('subscribed', on);
|
||||||
|
const base = [];
|
||||||
|
if(p.kind==='series') base.push('event-series');
|
||||||
|
if(p.kind==='movie') base.push('event-movie');
|
||||||
|
if(on) base.push('subscribed-event');
|
||||||
|
ev.setProp('classNames', base);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
calendar.rerenderEvents();
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Subscribarr – Übersicht{% endblock %}
|
{% block title %}Subscribarr – Overview{% endblock %}
|
||||||
|
|
||||||
{% block extra_style %}
|
{% block extra_style %}
|
||||||
<link rel="stylesheet" href="{% static 'css/index.css' %}">
|
<link rel="stylesheet" href="{% static 'css/index.css' %}">
|
||||||
@@ -20,26 +20,30 @@
|
|||||||
<div class="controls">
|
<div class="controls">
|
||||||
<form method="get" class="controls-form">
|
<form method="get" class="controls-form">
|
||||||
<input type="hidden" name="kind" value="{{ kind|default:'all' }}">
|
<input type="hidden" name="kind" value="{{ kind|default:'all' }}">
|
||||||
<input type="text" name="q" placeholder="Suche Serien/Filme…" value="{{ query|default:'' }}">
|
<input type="text" name="q" placeholder="Search series/movies…" value="{{ query|default:'' }}">
|
||||||
<input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Zeitraum in Tagen">
|
<input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Time range (days)">
|
||||||
<button type="submit">Suchen</button>
|
<button type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<nav class="seg" aria-label="Typ filtern">
|
<nav class="seg" aria-label="Filter type">
|
||||||
{% with qs=query|urlencode %}
|
{% with qs=query|urlencode %}
|
||||||
<a href="?kind=all&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
|
<a href="?kind=all&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
|
||||||
class="{% if kind == 'all' %}active{% endif %}">Alle</a>
|
class="{% if kind == 'all' %}active{% endif %}">All</a>
|
||||||
<a href="?kind=series&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
|
<a href="?kind=series&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
|
||||||
class="{% if kind == 'series' %}active{% endif %}">Serien</a>
|
class="{% if kind == 'series' %}active{% endif %}">Series</a>
|
||||||
<a href="?kind=movies&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
|
<a href="?kind=movies&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
|
||||||
class="{% if kind == 'movies' %}active{% endif %}">Filme</a>
|
class="{% if kind == 'movies' %}active{% endif %}">Movies</a>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="controls-actions">
|
||||||
|
<a href="/calendar/" class="btn btn-accent" title="Open calendar">📅 Calendar</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if show_series %}
|
{% if show_series %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Laufende Serien</h2>
|
<h2>Ongoing series</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{% for s in series_grouped %}
|
{% for s in series_grouped %}
|
||||||
<div class="card" data-series-id="{{ s.seriesId }}" data-title="{{ s.seriesTitle|escape }}"
|
<div class="card" data-series-id="{{ s.seriesId }}" data-title="{{ s.seriesTitle|escape }}"
|
||||||
@@ -60,7 +64,7 @@
|
|||||||
<span class="muted" data-dt="{{ e.airDateUtc|default:'' }}"></span>
|
<span class="muted" data-dt="{{ e.airDateUtc|default:'' }}"></span>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="muted">Keine kommenden Episoden.</div>
|
<div class="muted">No upcoming episodes.</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +76,7 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<p class="muted">Keine Serien gefunden.</p>
|
<p class="muted">No series found.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +84,7 @@
|
|||||||
|
|
||||||
{% if show_movies %}
|
{% if show_movies %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Anstehende Filme</h2>
|
<h2>Upcoming movies</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{% for m in movies %}
|
{% for m in movies %}
|
||||||
<div class="movie-card" data-kind="movie" data-title="{{ m.title|escape }}"
|
<div class="movie-card" data-kind="movie" data-title="{{ m.title|escape }}"
|
||||||
@@ -92,13 +96,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="title">{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}</div>
|
<div class="title">{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}</div>
|
||||||
<div class="muted">
|
<div class="muted">
|
||||||
{% if m.inCinemas %}Kino: <span data-dt="{{ m.inCinemas }}"></span>{% endif %}
|
{% if m.inCinemas %}In theaters: <span data-dt="{{ m.inCinemas }}"></span>{% endif %}
|
||||||
{% if m.digitalRelease %}<br>Digital: <span data-dt="{{ m.digitalRelease }}"></span>{% endif %}
|
{% if m.digitalRelease %}<br>Digital: <span data-dt="{{ m.digitalRelease }}"></span>{% endif %}
|
||||||
{% if m.physicalRelease %}<br>Disc: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %}
|
{% if m.physicalRelease %}<br>Physical: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<p class="muted">Keine Filme gefunden.</p>
|
<p class="muted">No movies found.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,17 +122,17 @@
|
|||||||
<div id="mSub" class="m-sub"></div>
|
<div id="mSub" class="m-sub"></div>
|
||||||
<div id="mBadges" class="badges"></div>
|
<div id="mBadges" class="badges"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close" title="Schließen" aria-label="Schließen">×</button>
|
<button class="modal-close" title="Close" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<div class="section-title">Beschreibung</div>
|
<div class="section-title">Overview</div>
|
||||||
<div id="mOverview" class="desc muted"></div>
|
<div id="mOverview" class="desc muted"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<div class="section-title">Kommende Episoden</div>
|
<div class="section-title">Upcoming episodes</div>
|
||||||
<div class="section-divider"></div>
|
<div class="section-divider"></div>
|
||||||
<div id="mEpisodes"></div>
|
<div id="mEpisodes"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,6 +157,7 @@
|
|||||||
const mSub = $("#mSub");
|
const mSub = $("#mSub");
|
||||||
const epSection = mEpisodes.closest(".section-block");
|
const epSection = mEpisodes.closest(".section-block");
|
||||||
const subscribeBtn = $("#subscribeBtn");
|
const subscribeBtn = $("#subscribeBtn");
|
||||||
|
const bc = ('BroadcastChannel' in window) ? new BroadcastChannel('subscribarr-sub') : null;
|
||||||
|
|
||||||
let lastClickedCard = null;
|
let lastClickedCard = null;
|
||||||
|
|
||||||
@@ -178,7 +183,7 @@
|
|||||||
return "movie:" + (card.dataset.title || "");
|
return "movie:" + (card.dataset.title || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache für Abonnement-Status
|
// Cache for subscription state
|
||||||
const subCache = new Map();
|
const subCache = new Map();
|
||||||
|
|
||||||
async function loadAllSubs() {
|
async function loadAllSubs() {
|
||||||
@@ -207,7 +212,7 @@
|
|||||||
return k ? subCache.get(k) || false : false;
|
return k ? subCache.get(k) || false : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSub(card, on) {
|
async function saveSub(card, on) {
|
||||||
const k = subKey(card);
|
const k = subKey(card);
|
||||||
if (!k) return;
|
if (!k) return;
|
||||||
const [type, id] = k.split(":");
|
const [type, id] = k.split(":");
|
||||||
@@ -221,45 +226,48 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resp.status === 403) {
|
if (resp.status === 403) {
|
||||||
// Nicht eingeloggt
|
// Not logged in
|
||||||
window.location.href = '/accounts/login/?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '/accounts/login/?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
|
||||||
// Cache aktualisieren
|
// Update cache
|
||||||
if (on) {
|
if (on) {
|
||||||
subCache.set(k, true);
|
subCache.set(k, true);
|
||||||
} else {
|
} else {
|
||||||
subCache.delete(k);
|
subCache.delete(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-tab/page notify
|
||||||
|
if (bc) bc.postMessage({ type: 'sub_change', kind: type, key: id, on });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update subscription:", err);
|
console.error("Failed to update subscription:", err);
|
||||||
// Cache-Update rückgängig machen bei Fehler
|
// Revert optimistic cache on error
|
||||||
if (on) {
|
if (on) {
|
||||||
subCache.delete(k);
|
subCache.delete(k);
|
||||||
} else {
|
} else {
|
||||||
subCache.set(k, true);
|
subCache.set(k, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fehlermeldung anzeigen
|
// Show error
|
||||||
const errorMsg = document.createElement('div');
|
const errorMsg = document.createElement('div');
|
||||||
errorMsg.className = 'error-message';
|
errorMsg.className = 'error-message';
|
||||||
errorMsg.textContent = 'Fehler beim Aktualisieren des Abonnements. Bitte versuchen Sie es später erneut.';
|
errorMsg.textContent = 'Failed to update subscription. Please try again later.';
|
||||||
document.body.appendChild(errorMsg);
|
document.body.appendChild(errorMsg);
|
||||||
setTimeout(() => errorMsg.remove(), 3000);
|
setTimeout(() => errorMsg.remove(), 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function applySubUI(card, on) {
|
function applySubUI(card, on) {
|
||||||
if (card) card.classList.toggle("subscribed", !!on); // grüne Outline via .subscribed (CSS)
|
if (card) card.classList.toggle("subscribed", !!on); // green outline via .subscribed (CSS)
|
||||||
if (subscribeBtn) {
|
if (subscribeBtn) {
|
||||||
subscribeBtn.textContent = on ? "Unsubscribe" : "Subscribe";
|
subscribeBtn.textContent = on ? "Unsubscribe" : "Subscribe";
|
||||||
subscribeBtn.setAttribute("aria-pressed", on ? "true" : "false");
|
subscribeBtn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beim Laden: Alle Abonnements in einem API-Call laden
|
// On load: fetch all subscriptions in a single API call
|
||||||
(async () => {
|
(async () => {
|
||||||
await loadAllSubs();
|
await loadAllSubs();
|
||||||
const cards = $$(".card, .movie-card");
|
const cards = $$(".card, .movie-card");
|
||||||
@@ -268,7 +276,30 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ===== Serien-Karten öffnen =====
|
// Listen to subscription changes from other pages (e.g., calendar)
|
||||||
|
if (bc) {
|
||||||
|
bc.onmessage = (evt) => {
|
||||||
|
const msg = evt.data || {};
|
||||||
|
if (msg.type !== 'sub_change') return;
|
||||||
|
const { kind, key, on } = msg;
|
||||||
|
// Keep cache in sync
|
||||||
|
const cacheKey = `${kind}:${key}`;
|
||||||
|
if (on) subCache.set(cacheKey, true); else subCache.delete(cacheKey);
|
||||||
|
|
||||||
|
// Update matching cards
|
||||||
|
const cards = $$(".card, .movie-card");
|
||||||
|
cards.forEach(card => {
|
||||||
|
const isSeries = card.classList.contains('card') && card.dataset.seriesId;
|
||||||
|
const isMovie = card.classList.contains('movie-card') && card.dataset.title;
|
||||||
|
let match = false;
|
||||||
|
if (kind === 'series' && isSeries && String(card.dataset.seriesId) === String(key)) match = true;
|
||||||
|
if (kind === 'movie' && isMovie && (card.dataset.title || '') === key) match = true;
|
||||||
|
if (match) applySubUI(card, on);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Open series cards =====
|
||||||
$$(".card").forEach(card => {
|
$$(".card").forEach(card => {
|
||||||
card.addEventListener("click", () => {
|
card.addEventListener("click", () => {
|
||||||
lastClickedCard = card;
|
lastClickedCard = card;
|
||||||
@@ -278,22 +309,22 @@
|
|||||||
const poster = card.dataset.poster || "";
|
const poster = card.dataset.poster || "";
|
||||||
const overview = card.dataset.overview || "";
|
const overview = card.dataset.overview || "";
|
||||||
|
|
||||||
// Episoden aus eingebettetem JSON <script id="eps-<id>">
|
// Episodes from embedded JSON <script id="eps-<id>">
|
||||||
let episodes = [];
|
let episodes = [];
|
||||||
const script = document.getElementById("eps-" + id);
|
const script = document.getElementById("eps-" + id);
|
||||||
if (script) { try { episodes = JSON.parse(script.textContent); } catch { } }
|
if (script) { try { episodes = JSON.parse(script.textContent); } catch { } }
|
||||||
|
|
||||||
// Modal befüllen
|
// Fill modal
|
||||||
mTitle.textContent = title;
|
mTitle.textContent = title;
|
||||||
mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster";
|
mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster";
|
||||||
mPoster.alt = title;
|
mPoster.alt = title;
|
||||||
mOverview.textContent = overview || "Keine Beschreibung verfügbar.";
|
mOverview.textContent = overview || "No overview available.";
|
||||||
|
|
||||||
mSub.textContent = episodes.length
|
mSub.textContent = episodes.length
|
||||||
? `${episodes.length} kommende Episode(n)`
|
? `${episodes.length} upcoming episode(s)`
|
||||||
: "Keine kommenden Episoden";
|
: "No upcoming episodes";
|
||||||
|
|
||||||
// Genres-Badges, falls data-genres vorhanden
|
// Genre badges if data-genres is present
|
||||||
mBadges.innerHTML = "";
|
mBadges.innerHTML = "";
|
||||||
if (card.dataset.genres) {
|
if (card.dataset.genres) {
|
||||||
card.dataset.genres.split(",").map(s => s.trim()).filter(Boolean).forEach(g => {
|
card.dataset.genres.split(",").map(s => s.trim()).filter(Boolean).forEach(g => {
|
||||||
@@ -304,7 +335,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Episodenbereich
|
// Episodes section
|
||||||
epSection.style.display = "";
|
epSection.style.display = "";
|
||||||
mEpisodes.innerHTML = "";
|
mEpisodes.innerHTML = "";
|
||||||
if (!episodes.length) {
|
if (!episodes.length) {
|
||||||
@@ -323,10 +354,10 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe-UI für diese Karte setzen
|
// Set subscribe UI for this card
|
||||||
applySubUI(lastClickedCard, loadSub(lastClickedCard));
|
applySubUI(lastClickedCard, loadSub(lastClickedCard));
|
||||||
|
|
||||||
// Status nochmal aktualisieren zur Sicherheit
|
// Refresh status again for safety
|
||||||
loadAllSubs().then(() => {
|
loadAllSubs().then(() => {
|
||||||
applySubUI(lastClickedCard, loadSub(lastClickedCard));
|
applySubUI(lastClickedCard, loadSub(lastClickedCard));
|
||||||
});
|
});
|
||||||
@@ -335,7 +366,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Film-Karten öffnen =====
|
// ===== Open movie cards =====
|
||||||
$$(".movie-card").forEach(card => {
|
$$(".movie-card").forEach(card => {
|
||||||
card.addEventListener("click", () => {
|
card.addEventListener("click", () => {
|
||||||
lastClickedCard = card;
|
lastClickedCard = card;
|
||||||
@@ -347,19 +378,19 @@
|
|||||||
mTitle.textContent = title;
|
mTitle.textContent = title;
|
||||||
mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster";
|
mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster";
|
||||||
mPoster.alt = title;
|
mPoster.alt = title;
|
||||||
mOverview.textContent = overview || "Keine Beschreibung verfügbar.";
|
mOverview.textContent = overview || "No overview available.";
|
||||||
|
|
||||||
mSub.textContent = "";
|
mSub.textContent = "";
|
||||||
mBadges.innerHTML = "";
|
mBadges.innerHTML = "";
|
||||||
|
|
||||||
// Episodenbereich ausblenden
|
// Hide episodes section
|
||||||
epSection.style.display = "none";
|
epSection.style.display = "none";
|
||||||
mEpisodes.innerHTML = "";
|
mEpisodes.innerHTML = "";
|
||||||
|
|
||||||
// Subscribe-UI für diese Karte setzen
|
// Set subscribe UI for this card
|
||||||
applySubUI(lastClickedCard, loadSub(lastClickedCard));
|
applySubUI(lastClickedCard, loadSub(lastClickedCard));
|
||||||
|
|
||||||
// Status nochmal aktualisieren zur Sicherheit
|
// Refresh status again for safety
|
||||||
loadAllSubs().then(() => {
|
loadAllSubs().then(() => {
|
||||||
applySubUI(lastClickedCard, loadSub(lastClickedCard));
|
applySubUI(lastClickedCard, loadSub(lastClickedCard));
|
||||||
});
|
});
|
||||||
@@ -368,7 +399,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Subscribe-Button im Modal mit Backend-Sync =====
|
// ===== Subscribe button in modal with backend sync =====
|
||||||
if (subscribeBtn) {
|
if (subscribeBtn) {
|
||||||
subscribeBtn.addEventListener("click", async () => {
|
subscribeBtn.addEventListener("click", async () => {
|
||||||
if (!lastClickedCard) return;
|
if (!lastClickedCard) return;
|
||||||
@@ -378,16 +409,16 @@
|
|||||||
// Optimistic UI update
|
// Optimistic UI update
|
||||||
applySubUI(lastClickedCard, newState);
|
applySubUI(lastClickedCard, newState);
|
||||||
|
|
||||||
// Backend-Sync
|
// Backend sync
|
||||||
await saveSub(lastClickedCard, newState);
|
await saveSub(lastClickedCard, newState);
|
||||||
|
|
||||||
// Status neu laden zur Sicherheit
|
// Refresh status again for safety
|
||||||
const finalState = await loadSub(lastClickedCard);
|
const finalState = await loadSub(lastClickedCard);
|
||||||
applySubUI(lastClickedCard, finalState);
|
applySubUI(lastClickedCard, finalState);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Datumsangaben in der Übersicht formatieren =====
|
// ===== Format date/time labels in the overview =====
|
||||||
document.querySelectorAll("[data-dt]").forEach(el => {
|
document.querySelectorAll("[data-dt]").forEach(el => {
|
||||||
const v = el.getAttribute("data-dt");
|
const v = el.getAttribute("data-dt");
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
@@ -2,13 +2,17 @@ from django.urls import path
|
|||||||
from .views import (
|
from .views import (
|
||||||
ArrIndexView, SeriesSubscribeView, SeriesUnsubscribeView,
|
ArrIndexView, SeriesSubscribeView, SeriesUnsubscribeView,
|
||||||
MovieSubscribeView, MovieUnsubscribeView,
|
MovieSubscribeView, MovieUnsubscribeView,
|
||||||
ListSeriesSubscriptionsView, ListMovieSubscriptionsView
|
ListSeriesSubscriptionsView, ListMovieSubscriptionsView,
|
||||||
|
CalendarView, CalendarEventsApi,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = 'arr_api'
|
app_name = 'arr_api'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', ArrIndexView.as_view(), name='index'),
|
path('', ArrIndexView.as_view(), name='index'),
|
||||||
|
# Calendar
|
||||||
|
path('calendar/', CalendarView.as_view(), name='calendar'),
|
||||||
|
path('api/calendar/events/', CalendarEventsApi.as_view(), name='calendar-events'),
|
||||||
|
|
||||||
# Series URLs
|
# Series URLs
|
||||||
path('api/series/subscribe/<int:series_id>/', SeriesSubscribeView.as_view(), name='subscribe-series'),
|
path('api/series/subscribe/<int:series_id>/', SeriesSubscribeView.as_view(), name='subscribe-series'),
|
||||||
|
@@ -13,6 +13,7 @@ from rest_framework import status
|
|||||||
from settingspanel.models import AppSettings
|
from settingspanel.models import AppSettings
|
||||||
from .services import sonarr_calendar, radarr_calendar, ArrServiceError
|
from .services import sonarr_calendar, radarr_calendar, ArrServiceError
|
||||||
from .models import SeriesSubscription, MovieSubscription
|
from .models import SeriesSubscription, MovieSubscription
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
def _get_int(request, key, default):
|
def _get_int(request, key, default):
|
||||||
@@ -67,13 +68,13 @@ class ArrIndexView(View):
|
|||||||
try:
|
try:
|
||||||
eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
|
eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
|
||||||
except ArrServiceError as e:
|
except ArrServiceError as e:
|
||||||
messages.error(request, f"Sonarr nicht erreichbar: {e}")
|
messages.error(request, f"Sonarr is not reachable: {e}")
|
||||||
|
|
||||||
# Radarr robust laden
|
# Radarr robust laden
|
||||||
try:
|
try:
|
||||||
movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
|
movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
|
||||||
except ArrServiceError as e:
|
except ArrServiceError as e:
|
||||||
messages.error(request, f"Radarr nicht erreichbar: {e}")
|
messages.error(request, f"Radarr is not reachable: {e}")
|
||||||
|
|
||||||
# Suche
|
# Suche
|
||||||
if q:
|
if q:
|
||||||
@@ -128,6 +129,74 @@ class ArrIndexView(View):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarView(View):
|
||||||
|
def get(self, request):
|
||||||
|
days = _get_int(request, "days", 60)
|
||||||
|
return render(request, "arr_api/calendar.html", {"days": days})
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name='dispatch')
|
||||||
|
class CalendarEventsApi(APIView):
|
||||||
|
def get(self, request):
|
||||||
|
days = _get_int(request, "days", 60)
|
||||||
|
conf = _arr_conf_from_db()
|
||||||
|
try:
|
||||||
|
eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
|
||||||
|
except ArrServiceError:
|
||||||
|
eps = []
|
||||||
|
try:
|
||||||
|
movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
|
||||||
|
except ArrServiceError:
|
||||||
|
movies = []
|
||||||
|
|
||||||
|
series_sub = set(SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True))
|
||||||
|
movie_sub_titles = set(MovieSubscription.objects.filter(user=request.user).values_list('title', flat=True))
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for e in eps:
|
||||||
|
when = e.get("airDateUtc")
|
||||||
|
if not when:
|
||||||
|
continue
|
||||||
|
events.append({
|
||||||
|
"id": f"s:{e.get('seriesId')}:{e.get('episodeId')}",
|
||||||
|
"title": f"{e.get('seriesTitle','')} — S{e.get('seasonNumber')}E{e.get('episodeNumber')}",
|
||||||
|
"start": when,
|
||||||
|
"allDay": False,
|
||||||
|
"extendedProps": {
|
||||||
|
"kind": "series",
|
||||||
|
"seriesId": e.get('seriesId'),
|
||||||
|
"seriesTitle": e.get('seriesTitle'),
|
||||||
|
"seasonNumber": e.get('seasonNumber'),
|
||||||
|
"episodeNumber": e.get('episodeNumber'),
|
||||||
|
"episodeTitle": e.get('title'),
|
||||||
|
"overview": e.get('seriesOverview') or "",
|
||||||
|
"poster": e.get('seriesPoster') or "",
|
||||||
|
"subscribed": int(e.get('seriesId') or 0) in series_sub,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for m in movies:
|
||||||
|
when = m.get('digitalRelease') or m.get('physicalRelease') or m.get('inCinemas')
|
||||||
|
if not when:
|
||||||
|
continue
|
||||||
|
events.append({
|
||||||
|
"id": f"m:{m.get('movieId') or m.get('title')}",
|
||||||
|
"title": m.get('title') or "(movie)",
|
||||||
|
"start": when,
|
||||||
|
"allDay": True,
|
||||||
|
"extendedProps": {
|
||||||
|
"kind": "movie",
|
||||||
|
"movieId": m.get('movieId'),
|
||||||
|
"title": m.get('title'),
|
||||||
|
"overview": m.get('overview') or "",
|
||||||
|
"poster": m.get('posterUrl') or "",
|
||||||
|
"subscribed": (m.get('title') or '') in movie_sub_titles,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({"events": events})
|
||||||
|
|
||||||
|
|
||||||
class SubscribeSeriesView(View):
|
class SubscribeSeriesView(View):
|
||||||
@method_decorator(require_POST)
|
@method_decorator(require_POST)
|
||||||
def post(self, request, series_id):
|
def post(self, request, series_id):
|
||||||
@@ -145,9 +214,9 @@ class SubscribeSeriesView(View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
messages.success(request, f'Serie "{series_data["series_title"]}" wurde abonniert!')
|
messages.success(request, f'Subscribed to series "{series_data["series_title"]}"!')
|
||||||
else:
|
else:
|
||||||
messages.info(request, f'Serie "{series_data["series_title"]}" war bereits abonniert.')
|
messages.info(request, f'Series "{series_data["series_title"]}" was already subscribed.')
|
||||||
|
|
||||||
return redirect('arr_api:index')
|
return redirect('arr_api:index')
|
||||||
|
|
||||||
@@ -157,7 +226,7 @@ class UnsubscribeSeriesView(View):
|
|||||||
subscription = get_object_or_404(SeriesSubscription, series_id=series_id)
|
subscription = get_object_or_404(SeriesSubscription, series_id=series_id)
|
||||||
series_title = subscription.series_title
|
series_title = subscription.series_title
|
||||||
subscription.delete()
|
subscription.delete()
|
||||||
messages.success(request, f'Abonnement für "{series_title}" wurde beendet.')
|
messages.success(request, f'Subscription for "{series_title}" has been removed.')
|
||||||
return redirect('arr_api:index')
|
return redirect('arr_api:index')
|
||||||
|
|
||||||
class SubscribeMovieView(View):
|
class SubscribeMovieView(View):
|
||||||
@@ -178,9 +247,9 @@ class SubscribeMovieView(View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
messages.success(request, f'Film "{movie_data["title"]}" wurde abonniert!')
|
messages.success(request, f'Subscribed to movie "{movie_data["title"]}"!')
|
||||||
else:
|
else:
|
||||||
messages.info(request, f'Film "{movie_data["title"]}" war bereits abonniert.')
|
messages.info(request, f'Movie "{movie_data["title"]}" was already subscribed.')
|
||||||
|
|
||||||
return redirect('arr_api:index')
|
return redirect('arr_api:index')
|
||||||
|
|
||||||
@@ -190,14 +259,14 @@ class UnsubscribeMovieView(View):
|
|||||||
subscription = get_object_or_404(MovieSubscription, movie_id=movie_id)
|
subscription = get_object_or_404(MovieSubscription, movie_id=movie_id)
|
||||||
movie_title = subscription.title
|
movie_title = subscription.title
|
||||||
subscription.delete()
|
subscription.delete()
|
||||||
messages.success(request, f'Abonnement für "{movie_title}" wurde beendet.')
|
messages.success(request, f'Subscription for "{movie_title}" has been removed.')
|
||||||
return redirect('arr_api:index')
|
return redirect('arr_api:index')
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@login_required
|
@login_required
|
||||||
def subscribe_series(request, series_id):
|
def subscribe_series(request, series_id):
|
||||||
"""Serie abonnieren"""
|
"""Subscribe to a series"""
|
||||||
try:
|
try:
|
||||||
# Existiert bereits?
|
# Existiert bereits?
|
||||||
if SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists():
|
if SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists():
|
||||||
@@ -224,7 +293,7 @@ def subscribe_series(request, series_id):
|
|||||||
@require_POST
|
@require_POST
|
||||||
@login_required
|
@login_required
|
||||||
def unsubscribe_series(request, series_id):
|
def unsubscribe_series(request, series_id):
|
||||||
"""Serie deabonnieren"""
|
"""Unsubscribe from a series"""
|
||||||
try:
|
try:
|
||||||
SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete()
|
SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete()
|
||||||
return JsonResponse({'success': True})
|
return JsonResponse({'success': True})
|
||||||
@@ -233,14 +302,14 @@ def unsubscribe_series(request, series_id):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def is_subscribed_series(request, series_id):
|
def is_subscribed_series(request, series_id):
|
||||||
"""Prüfe ob Serie abonniert ist"""
|
"""Check if a series is subscribed"""
|
||||||
is_subbed = SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists()
|
is_subbed = SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists()
|
||||||
return JsonResponse({'subscribed': is_subbed})
|
return JsonResponse({'subscribed': is_subbed})
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@login_required
|
@login_required
|
||||||
def subscribe_movie(request, movie_id):
|
def subscribe_movie(request, movie_id):
|
||||||
"""Film abonnieren"""
|
"""Subscribe to a movie"""
|
||||||
try:
|
try:
|
||||||
# Existiert bereits?
|
# Existiert bereits?
|
||||||
if MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists():
|
if MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists():
|
||||||
@@ -268,7 +337,7 @@ def subscribe_movie(request, movie_id):
|
|||||||
@require_POST
|
@require_POST
|
||||||
@login_required
|
@login_required
|
||||||
def unsubscribe_movie(request, movie_id):
|
def unsubscribe_movie(request, movie_id):
|
||||||
"""Film deabonnieren"""
|
"""Unsubscribe from a movie"""
|
||||||
try:
|
try:
|
||||||
MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).delete()
|
MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).delete()
|
||||||
return JsonResponse({'success': True})
|
return JsonResponse({'success': True})
|
||||||
@@ -277,13 +346,13 @@ def unsubscribe_movie(request, movie_id):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def is_subscribed_movie(request, movie_id):
|
def is_subscribed_movie(request, movie_id):
|
||||||
"""Prüfe ob Film abonniert ist"""
|
"""Check if a movie is subscribed"""
|
||||||
is_subbed = MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists()
|
is_subbed = MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists()
|
||||||
return JsonResponse({'subscribed': is_subbed})
|
return JsonResponse({'subscribed': is_subbed})
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def get_subscriptions(request):
|
def get_subscriptions(request):
|
||||||
"""Hole alle Abonnements des Users"""
|
"""Get all subscriptions for the user"""
|
||||||
series = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True)
|
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)
|
movies = MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True)
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
|
@@ -4,7 +4,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: subscribarr
|
container_name: subscribarr
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8081:8000"
|
||||||
environment:
|
environment:
|
||||||
# Django
|
# Django
|
||||||
- DJANGO_DEBUG=true
|
- DJANGO_DEBUG=true
|
||||||
@@ -13,22 +13,22 @@ services:
|
|||||||
- DB_PATH=/app/data/db.sqlite3
|
- DB_PATH=/app/data/db.sqlite3
|
||||||
- NOTIFICATIONS_ALLOW_DUPLICATES=false
|
- NOTIFICATIONS_ALLOW_DUPLICATES=false
|
||||||
# App Settings (optional, otherwise use first-run setup)
|
# App Settings (optional, otherwise use first-run setup)
|
||||||
- JELLYFIN_URL=
|
#- JELLYFIN_URL=
|
||||||
- JELLYFIN_API_KEY=
|
#- JELLYFIN_API_KEY=
|
||||||
- SONARR_URL=
|
#- SONARR_URL=
|
||||||
- SONARR_API_KEY=
|
#- SONARR_API_KEY=
|
||||||
- RADARR_URL=
|
#- RADARR_URL=
|
||||||
- RADARR_API_KEY=
|
#- RADARR_API_KEY=
|
||||||
- MAIL_HOST=
|
#- MAIL_HOST=
|
||||||
- MAIL_PORT=
|
#- MAIL_PORT=
|
||||||
- MAIL_SECURE=
|
#- MAIL_SECURE=
|
||||||
- MAIL_USER=
|
#- MAIL_USER=
|
||||||
- MAIL_PASSWORD=
|
#- MAIL_PASSWORD=
|
||||||
- MAIL_FROM=
|
#- MAIL_FROM=
|
||||||
# Admin bootstrap (optional)
|
# Admin bootstrap (optional)
|
||||||
- ADMIN_USERNAME=
|
#- ADMIN_USERNAME=
|
||||||
- ADMIN_PASSWORD=
|
#- ADMIN_PASSWORD=
|
||||||
- ADMIN_EMAIL=
|
#- ADMIN_EMAIL=
|
||||||
# Cron schedule (default every 30min)
|
# Cron schedule (default every 30min)
|
||||||
- CRON_SCHEDULE=*/30 * * * *
|
- CRON_SCHEDULE=*/30 * * * *
|
||||||
volumes:
|
volumes:
|
||||||
|
@@ -7,20 +7,20 @@ class FirstRunSetupForm(forms.Form):
|
|||||||
jellyfin_server_url = forms.URLField(
|
jellyfin_server_url = forms.URLField(
|
||||||
label="Jellyfin Server URL",
|
label="Jellyfin Server URL",
|
||||||
required=True,
|
required=True,
|
||||||
help_text="Die URL deines Jellyfin-Servers"
|
help_text="URL of your Jellyfin server"
|
||||||
)
|
)
|
||||||
jellyfin_api_key = forms.CharField(
|
jellyfin_api_key = forms.CharField(
|
||||||
label="Jellyfin API Key",
|
label="Jellyfin API Key",
|
||||||
required=True,
|
required=True,
|
||||||
widget=forms.PasswordInput(render_value=True),
|
widget=forms.PasswordInput(render_value=True),
|
||||||
help_text="Der API-Key aus den Jellyfin-Einstellungen"
|
help_text="API key from Jellyfin settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sonarr (Optional)
|
# Sonarr (Optional)
|
||||||
sonarr_url = forms.URLField(
|
sonarr_url = forms.URLField(
|
||||||
label="Sonarr URL",
|
label="Sonarr URL",
|
||||||
required=False,
|
required=False,
|
||||||
help_text="Die URL deines Sonarr-Servers"
|
help_text="URL of your Sonarr server"
|
||||||
)
|
)
|
||||||
sonarr_api_key = forms.CharField(
|
sonarr_api_key = forms.CharField(
|
||||||
label="Sonarr API Key",
|
label="Sonarr API Key",
|
||||||
@@ -32,7 +32,7 @@ class FirstRunSetupForm(forms.Form):
|
|||||||
radarr_url = forms.URLField(
|
radarr_url = forms.URLField(
|
||||||
label="Radarr URL",
|
label="Radarr URL",
|
||||||
required=False,
|
required=False,
|
||||||
help_text="Die URL deines Radarr-Servers"
|
help_text="URL of your Radarr server"
|
||||||
)
|
)
|
||||||
radarr_api_key = forms.CharField(
|
radarr_api_key = forms.CharField(
|
||||||
label="Radarr API Key",
|
label="Radarr API Key",
|
||||||
@@ -45,13 +45,13 @@ class JellyfinSettingsForm(forms.Form):
|
|||||||
label="Jellyfin Server URL",
|
label="Jellyfin Server URL",
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.URLInput(attrs=WIDE),
|
widget=forms.URLInput(attrs=WIDE),
|
||||||
help_text="z.B. http://localhost:8096"
|
help_text="e.g. http://localhost:8096"
|
||||||
)
|
)
|
||||||
jellyfin_api_key = forms.CharField(
|
jellyfin_api_key = forms.CharField(
|
||||||
label="Jellyfin API Key",
|
label="Jellyfin API Key",
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.PasswordInput(render_value=True, attrs=WIDE),
|
widget=forms.PasswordInput(render_value=True, attrs=WIDE),
|
||||||
help_text="Admin API Key aus den Jellyfin Einstellungen"
|
help_text="Admin API key from Jellyfin settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
class ArrSettingsForm(forms.Form):
|
class ArrSettingsForm(forms.Form):
|
||||||
@@ -68,18 +68,18 @@ class MailSettingsForm(forms.Form):
|
|||||||
mail_host = forms.CharField(label="Mail Host", required=False)
|
mail_host = forms.CharField(label="Mail Host", required=False)
|
||||||
mail_port = forms.IntegerField(label="Mail Port", required=False, min_value=1, max_value=65535)
|
mail_port = forms.IntegerField(label="Mail Port", required=False, min_value=1, max_value=65535)
|
||||||
mail_secure = forms.ChoiceField(
|
mail_secure = forms.ChoiceField(
|
||||||
label="Sicherheit", required=False,
|
label="Security", required=False,
|
||||||
choices=[("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")]
|
choices=[("", "No TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")]
|
||||||
)
|
)
|
||||||
mail_user = forms.CharField(label="Mail Benutzer", required=False)
|
mail_user = forms.CharField(label="Mail Username", required=False)
|
||||||
mail_password = forms.CharField(
|
mail_password = forms.CharField(
|
||||||
label="Mail Passwort", required=False,
|
label="Mail Password", required=False,
|
||||||
widget=forms.PasswordInput(render_value=True)
|
widget=forms.PasswordInput(render_value=True)
|
||||||
)
|
)
|
||||||
mail_from = forms.EmailField(label="Absender (From)", required=False)
|
mail_from = forms.EmailField(label="Sender (From)", required=False)
|
||||||
|
|
||||||
class AccountForm(forms.Form):
|
class AccountForm(forms.Form):
|
||||||
username = forms.CharField(label="Benutzername", required=False)
|
username = forms.CharField(label="Username", required=False)
|
||||||
email = forms.EmailField(label="E-Mail", required=False)
|
email = forms.EmailField(label="Email", required=False)
|
||||||
new_password = forms.CharField(label="Neues Passwort", required=False, widget=forms.PasswordInput)
|
new_password = forms.CharField(label="New password", required=False, widget=forms.PasswordInput)
|
||||||
repeat_password = forms.CharField(label="Passwort wiederholen", required=False, widget=forms.PasswordInput)
|
repeat_password = forms.CharField(label="Repeat password", required=False, widget=forms.PasswordInput)
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
class AppSettings(models.Model):
|
class AppSettings(models.Model):
|
||||||
# Singleton-Pattern über feste ID
|
# Singleton pattern via fixed ID
|
||||||
singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
|
singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
|
||||||
|
|
||||||
# Jellyfin
|
# Jellyfin
|
||||||
@@ -20,7 +20,7 @@ class AppSettings(models.Model):
|
|||||||
mail_secure = models.CharField(
|
mail_secure = models.CharField(
|
||||||
max_length=10, blank=True, null=True,
|
max_length=10, blank=True, null=True,
|
||||||
choices=(
|
choices=(
|
||||||
("", "Kein TLS/SSL"),
|
("", "No TLS/SSL"),
|
||||||
("starttls", "STARTTLS (Port 587)"),
|
("starttls", "STARTTLS (Port 587)"),
|
||||||
("ssl", "SSL/TLS (Port 465)"),
|
("ssl", "SSL/TLS (Port 465)"),
|
||||||
("tls", "TLS (alias STARTTLS)"),
|
("tls", "TLS (alias STARTTLS)"),
|
||||||
@@ -30,7 +30,7 @@ class AppSettings(models.Model):
|
|||||||
mail_password = models.CharField(max_length=255, blank=True, null=True)
|
mail_password = models.CharField(max_length=255, blank=True, null=True)
|
||||||
mail_from = models.EmailField(blank=True, null=True)
|
mail_from = models.EmailField(blank=True, null=True)
|
||||||
|
|
||||||
# „Account“
|
# Account
|
||||||
acc_username = models.CharField(max_length=150, blank=True, null=True)
|
acc_username = models.CharField(max_length=150, blank=True, null=True)
|
||||||
acc_email = models.EmailField(blank=True, null=True)
|
acc_email = models.EmailField(blank=True, null=True)
|
||||||
|
|
||||||
|
@@ -7,28 +7,28 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="setup-container">
|
<div class="setup-container">
|
||||||
<h1>Willkommen bei Subscribarr</h1>
|
<h1>Welcome to Subscribarr</h1>
|
||||||
<p class="setup-intro">Lass uns deine Installation einrichten. Du brauchst mindestens einen Jellyfin-Server.</p>
|
<p class="setup-intro">Let's set up your installation. You need at least one Jellyfin server.</p>
|
||||||
|
|
||||||
<form method="post" class="setup-form">
|
<form method="post" class="setup-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<h2>Jellyfin Server (Erforderlich)</h2>
|
<h2>Jellyfin server (required)</h2>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Server URL</label>
|
<label>Server URL</label>
|
||||||
{{ form.jellyfin_server_url }}
|
{{ form.jellyfin_server_url }}
|
||||||
<div class="help">z.B. http://192.168.1.100:8096 oder http://jellyfin.local:8096</div>
|
<div class="help">e.g., http://192.168.1.100:8096 or http://jellyfin.local:8096</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>API Key</label>
|
<label>API Key</label>
|
||||||
{{ form.jellyfin_api_key }}
|
{{ form.jellyfin_api_key }}
|
||||||
<div class="help">Admin API Key aus den Jellyfin-Einstellungen</div>
|
<div class="help">Admin API key from Jellyfin settings</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<h2>Sonarr (Optional)</h2>
|
<h2>Sonarr (optional)</h2>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Server URL</label>
|
<label>Server URL</label>
|
||||||
{{ form.sonarr_url }}
|
{{ form.sonarr_url }}
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<h2>Radarr (Optional)</h2>
|
<h2>Radarr (optional)</h2>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Server URL</label>
|
<label>Server URL</label>
|
||||||
{{ form.radarr_url }}
|
{{ form.radarr_url }}
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="setup-submit">Installation abschließen</button>
|
<button type="submit" class="setup-submit">Finish setup</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@@ -1,19 +1,22 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Einstellungen – Subscribarr</title>
|
<title>Settings – Subscribarr</title>
|
||||||
<link rel="stylesheet" href="{% static 'css/settings.css' %}">
|
<link rel="stylesheet" href="{% static 'css/settings.css' %}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<div><a href="/" class="btn">← Zurück</a></div>
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
<div><strong>Einstellungen</strong></div>
|
<a href="/" class="btn">← Back</a>
|
||||||
|
<a href="{% url 'settingspanel:subscriptions' %}" class="btn">👥 Subscriptions</a>
|
||||||
|
</div>
|
||||||
|
<div><strong>Settings</strong></div>
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,12 +32,12 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<label>Jellyfin Server URL</label>
|
<label>Jellyfin Server URL</label>
|
||||||
{{ jellyfin_form.jellyfin_server_url }}
|
{{ jellyfin_form.jellyfin_server_url }}
|
||||||
<div class="help">z.B. http://localhost:8096</div>
|
<div class="help">e.g., http://localhost:8096</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>Jellyfin API Key</label>
|
<label>Jellyfin API Key</label>
|
||||||
{{ jellyfin_form.jellyfin_api_key }}
|
{{ jellyfin_form.jellyfin_api_key }}
|
||||||
<div class="help">Admin API Key aus den Jellyfin Einstellungen</div>
|
<div class="help">Admin API key from your Jellyfin settings</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -46,8 +49,7 @@
|
|||||||
<div class="inline">
|
<div class="inline">
|
||||||
<div class="field">{{ arr_form.sonarr_url }}</div>
|
<div class="field">{{ arr_form.sonarr_url }}</div>
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
<button class="btn" type="button" onclick="testConnection('sonarr', this)">Test
|
<button class="btn" type="button" onclick="testConnection('sonarr', this)">Test Sonarr</button>
|
||||||
Sonarr</button>
|
|
||||||
<span id="sonarrStatus" class="badge muted">—</span>
|
<span id="sonarrStatus" class="badge muted">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,8 +66,7 @@
|
|||||||
<div class="inline">
|
<div class="inline">
|
||||||
<div class="field">{{ arr_form.radarr_url }}</div>
|
<div class="field">{{ arr_form.radarr_url }}</div>
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
<button class="btn" type="button" onclick="testConnection('radarr', this)">Test
|
<button class="btn" type="button" onclick="testConnection('radarr', this)">Test Radarr</button>
|
||||||
Radarr</button>
|
|
||||||
<span id="radarrStatus" class="badge muted">—</span>
|
<span id="radarrStatus" class="badge muted">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,32 +77,31 @@
|
|||||||
{{ arr_form.radarr_api_key }}
|
{{ arr_form.radarr_api_key }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="help">Klicke „Test …“, um die Verbindung gegen <code>/api/v3/system/status</code> zu
|
<div class="help">Click “Test …” to verify against <code>/api/v3/system/status</code>.</div>
|
||||||
prüfen.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Mailserver</h2>
|
<h2>Mail server</h2>
|
||||||
<div class="row"><label>Host</label>{{ mail_form.mail_host }}</div>
|
<div class="row"><label>Host</label>{{ mail_form.mail_host }}</div>
|
||||||
<div class="row"><label>Port</label>{{ mail_form.mail_port }}</div>
|
<div class="row"><label>Port</label>{{ mail_form.mail_port }}</div>
|
||||||
<div class="row"><label>Sicherheit</label>{{ mail_form.mail_secure }}</div>
|
<div class="row"><label>Security</label>{{ mail_form.mail_secure }}</div>
|
||||||
<div class="row"><label>Benutzer</label>{{ mail_form.mail_user }}</div>
|
<div class="row"><label>User</label>{{ mail_form.mail_user }}</div>
|
||||||
<div class="row"><label>Passwort</label>{{ mail_form.mail_password }}</div>
|
<div class="row"><label>Password</label>{{ mail_form.mail_password }}</div>
|
||||||
<div class="row"><label>Absender</label>{{ mail_form.mail_from }}</div>
|
<div class="row"><label>From</label>{{ mail_form.mail_from }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Konto</h2>
|
<h2>Account</h2>
|
||||||
<div class="row"><label>Benutzername</label>{{ account_form.username }}</div>
|
<div class="row"><label>Username</label>{{ account_form.username }}</div>
|
||||||
<div class="row"><label>E-Mail</label>{{ account_form.email }}</div>
|
<div class="row"><label>Email</label>{{ account_form.email }}</div>
|
||||||
<div class="row"><label>Neues Passwort</label>{{ account_form.new_password }}</div>
|
<div class="row"><label>New password</label>{{ account_form.new_password }}</div>
|
||||||
<div class="row"><label>Passwort wiederholen</label>{{ account_form.repeat_password }}</div>
|
<div class="row"><label>Repeat password</label>{{ account_form.repeat_password }}</div>
|
||||||
<div class="help">Nur Oberfläche – Umsetzung Passwortänderung später.</div>
|
<div class="help">Only UI – implementing password change later.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:16px">
|
<div style="margin-top:16px">
|
||||||
<button class="btn btn-primary" type="submit">Speichern</button>
|
<button class="btn btn-primary" type="submit">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,25 +116,25 @@
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
alert(kind + " Verbindung erfolgreich!");
|
alert(kind + " connection successful!");
|
||||||
} else {
|
} else {
|
||||||
alert(kind + " Fehler: " + data.error);
|
alert(kind + " error: " + data.error);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => alert(kind + " Fehler: " + err));
|
.catch(err => alert(kind + " error: " + err));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBadge(kind, state, text, tooltip) {
|
function setBadge(kind, state, text, tooltip) {
|
||||||
const el = document.getElementById(kind + "Status");
|
const el = document.getElementById(kind + "Status");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.classList.remove("ok", "err", "muted");
|
el.classList.remove("ok", "err", "muted");
|
||||||
el.title = tooltip || ""; // voller Fehlertext im Tooltip
|
el.title = tooltip || ""; // full error text in tooltip
|
||||||
if (state === "ok") {
|
if (state === "ok") {
|
||||||
el.classList.add("ok");
|
el.classList.add("ok");
|
||||||
el.textContent = "Verbunden";
|
el.textContent = "Connected";
|
||||||
} else if (state === "err") {
|
} else if (state === "err") {
|
||||||
el.classList.add("err");
|
el.classList.add("err");
|
||||||
el.textContent = "Fehler";
|
el.textContent = "Error";
|
||||||
} else {
|
} else {
|
||||||
el.classList.add("muted");
|
el.classList.add("muted");
|
||||||
el.textContent = "—";
|
el.textContent = "—";
|
||||||
@@ -147,19 +147,19 @@
|
|||||||
const url = urlEl ? urlEl.value.trim() : "";
|
const url = urlEl ? urlEl.value.trim() : "";
|
||||||
const key = keyEl ? keyEl.value.trim() : "";
|
const key = keyEl ? keyEl.value.trim() : "";
|
||||||
|
|
||||||
setBadge(kind, "muted", "Teste…");
|
setBadge(kind, "muted", "Testing…");
|
||||||
if (btnEl) { btnEl.disabled = true; }
|
if (btnEl) { btnEl.disabled = true; }
|
||||||
|
|
||||||
fetch(`/settings/test-connection/?kind=${encodeURIComponent(kind)}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`)
|
fetch(`/settings/test-connection/?kind=${encodeURIComponent(kind)}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
setBadge(kind, "ok", "Verbunden", "");
|
setBadge(kind, "ok", "Connected", "");
|
||||||
} else {
|
} else {
|
||||||
setBadge(kind, "err", "Fehler", data.error || "Unbekannter Fehler");
|
setBadge(kind, "err", "Error", data.error || "Unknown error");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => setBadge(kind, "err", "Fehler", String(err)))
|
.catch(err => setBadge(kind, "err", "Error", String(err)))
|
||||||
.finally(() => { if (btnEl) btnEl.disabled = false; });
|
.finally(() => { if (btnEl) btnEl.disabled = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
218
settingspanel/templates/settingspanel/subscriptions.html
Normal file
218
settingspanel/templates/settingspanel/subscriptions.html
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Subscriptions – Admin{% endblock %}
|
||||||
|
{% block extra_style %}
|
||||||
|
<style>
|
||||||
|
.wrap { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||||
|
.filters { display:flex; gap:8px; margin: 10px 0 16px; }
|
||||||
|
.filters input { padding: 10px 12px; border-radius: 10px; border: 1px solid #2a2a34; background:#111119; color:#e6e6e6; min-width: 240px; }
|
||||||
|
.section { margin-top: 20px; }
|
||||||
|
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||||
|
.card { background:#12121a; border:1px solid #1f2030; border-radius:12px; padding:12px; display:flex; gap:12px; }
|
||||||
|
.poster { width: 90px; height: 135px; border-radius:8px; overflow:hidden; background:#222233; flex:0 0 auto; }
|
||||||
|
.poster img { width:100%; height:100%; object-fit: cover; display:block; }
|
||||||
|
.meta { flex:1 1 auto; min-width:0; }
|
||||||
|
.title { font-weight:600; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.muted { color:#9aa0b4; font-size:.9rem; }
|
||||||
|
.user { font-weight:600; }
|
||||||
|
|
||||||
|
/* user list */
|
||||||
|
.users { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:10px; }
|
||||||
|
.user-item { background:#10121b; border:1px solid #1f2030; border-radius:10px; padding:10px 12px; display:flex; justify-content:space-between; align-items:center; cursor:pointer; transition: transform .08s ease, border-color .12s ease; }
|
||||||
|
.user-item:hover { transform: translateY(-1px); border-color:#2a2b44; }
|
||||||
|
.badge { background:#171a26; border:1px solid #2a2b44; color:#cfd3ea; border-radius:999px; padding:2px 8px; font-size:.85rem; }
|
||||||
|
|
||||||
|
/* modal fix */
|
||||||
|
.modal-backdrop { position:fixed; inset:0; display:none; align-items:center; justify-content:center; background: rgba(10,12,20,.55); backdrop-filter: blur(4px); z-index: 1000; }
|
||||||
|
.modal { width:min(720px, 100%); max-height:92vh; overflow:auto; background: linear-gradient(180deg, #10121b 0%, #0d0f16 100%); border:1px solid #2a2b44; border-radius: 14px; }
|
||||||
|
.modal-header { display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid #20223a; background: rgba(13,15,22,.85); position:sticky; top:0; }
|
||||||
|
.modal-close { margin-left:auto; background:#1a1f33; color:#c9cbe3; border:1px solid #2a2b44; width:34px; height:34px; border-radius:10px; cursor:pointer; }
|
||||||
|
.section-block { background:#101327; border:1px solid #20223a; border-radius:12px; padding:14px; margin:12px; }
|
||||||
|
.section-title { font-weight:650; margin-bottom:8px; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Subscriptions overview</h1>
|
||||||
|
<div class="filters">
|
||||||
|
<input type="text" id="q" placeholder="Search user/series/movies…">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Users</h2>
|
||||||
|
<p class="muted">Tap a user to view all subscriptions.</p>
|
||||||
|
<div class="users" id="usersList">
|
||||||
|
{% for u in user_stats %}
|
||||||
|
<div class="user-item" data-user="{{ u.username_lower }}">
|
||||||
|
<div>{{ u.username }}</div>
|
||||||
|
<div class="badge">{{ u.total_count }}</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="muted">No subscriptions yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Series</h2>
|
||||||
|
<div class="grid" id="seriesGrid">
|
||||||
|
{% for s in series %}
|
||||||
|
<div class="card" data-user="{{ s.user.username|lower }}" data-title="{{ s.series_title|lower }}" data-id="{{ s.series_id }}">
|
||||||
|
<div class="poster">
|
||||||
|
{% if s.series_poster %}
|
||||||
|
<img src="{{ s.series_poster }}" alt="{{ s.series_title }}">
|
||||||
|
{% else %}
|
||||||
|
<img src="https://via.placeholder.com/90x135?text=No+Poster" alt="">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="title">{{ s.series_title }}</div>
|
||||||
|
<div class="muted">User: <span class="user">{{ s.user.username }}</span></div>
|
||||||
|
<div class="muted">SeriesId: {{ s.series_id }}</div>
|
||||||
|
<div class="muted">Since: {{ s.created_at }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="muted">No series subscriptions.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Movies</h2>
|
||||||
|
<div class="grid" id="moviesGrid">
|
||||||
|
{% for m in movies %}
|
||||||
|
<div class="card" data-user="{{ m.user.username|lower }}" data-title="{{ m.title|lower }}" data-id="{{ m.movie_id }}">
|
||||||
|
<div class="poster">
|
||||||
|
{% if m.poster %}
|
||||||
|
<img src="{{ m.poster }}" alt="{{ m.title }}">
|
||||||
|
{% else %}
|
||||||
|
<img src="https://via.placeholder.com/90x135?text=No+Poster" alt="">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="title">{{ m.title }}</div>
|
||||||
|
<div class="muted">User: <span class="user">{{ m.user.username }}</span></div>
|
||||||
|
<div class="muted">MovieId: {{ m.movie_id }}</div>
|
||||||
|
<div class="muted">Since: {{ m.created_at }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="muted">No movie subscriptions.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const q = document.getElementById('q');
|
||||||
|
function filter(){
|
||||||
|
const v = (q.value||'').toLowerCase();
|
||||||
|
[document.getElementById('seriesGrid'), document.getElementById('moviesGrid')].forEach(grid=>{
|
||||||
|
if(!grid) return; Array.from(grid.children).forEach(card=>{
|
||||||
|
const text = (card.dataset.user + ' ' + card.dataset.title).toLowerCase();
|
||||||
|
card.style.display = text.includes(v) ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const users = document.getElementById('usersList');
|
||||||
|
if(users){ Array.from(users.children).forEach(item=>{
|
||||||
|
const username = item.querySelector('div')?.textContent?.toLowerCase() || '';
|
||||||
|
item.style.display = username.includes(v) ? '' : 'none';
|
||||||
|
}); }
|
||||||
|
}
|
||||||
|
q.addEventListener('input', filter);
|
||||||
|
|
||||||
|
// Group-by-user popup
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal-backdrop';
|
||||||
|
modal.style.display = 'none';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal" style="max-width:720px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div style="font-weight:700" id="uTitle"></div>
|
||||||
|
<button class="modal-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="section-block"><div class="section-title">Series</div><div id="uSeries"></div></div>
|
||||||
|
<div class="section-block"><div class="section-title">Movies</div><div id="uMovies"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
const closeBtn = modal.querySelector('.modal-close');
|
||||||
|
const uTitle = modal.querySelector('#uTitle');
|
||||||
|
const uSeries = modal.querySelector('#uSeries');
|
||||||
|
const uMovies = modal.querySelector('#uMovies');
|
||||||
|
function open(){ modal.style.display='flex'; document.body.style.overflow='hidden'; }
|
||||||
|
function close(){ modal.style.display='none'; document.body.style.overflow=''; }
|
||||||
|
closeBtn.addEventListener('click', close);
|
||||||
|
modal.addEventListener('click', e=>{ if(e.target===modal) close(); });
|
||||||
|
|
||||||
|
function renderList(container, items){
|
||||||
|
container.innerHTML = '';
|
||||||
|
if(!items.length){ container.innerHTML = '<div class="muted">—</div>'; return; }
|
||||||
|
items.forEach(it=>{
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.style.display = 'flex'; row.style.gap='10px'; row.style.alignItems='center'; row.style.margin='6px 0';
|
||||||
|
const poster = document.createElement('img');
|
||||||
|
poster.src = it.poster || 'https://via.placeholder.com/50x75?text=No+Poster';
|
||||||
|
poster.style.width='50px'; poster.style.height='75px'; poster.style.objectFit='cover'; poster.style.borderRadius='6px';
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.innerHTML = `<div style="font-weight:600">${it.title}</div><div class="muted">ID: ${it.id}</div>`;
|
||||||
|
row.appendChild(poster); row.appendChild(meta);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatherForUser(username){
|
||||||
|
const series = Array.from(document.querySelectorAll('#seriesGrid .card')).filter(c=>c.dataset.user===username).map(c=>{
|
||||||
|
const idFromAttr = c.dataset.id || '';
|
||||||
|
let idFromText = '';
|
||||||
|
if(!idFromAttr){
|
||||||
|
const text = Array.from(c.querySelectorAll('.muted')).map(el=>el.textContent).join(' ');
|
||||||
|
const m = text.match(/SeriesId:\s*(\d+)/i); idFromText = m ? m[1] : '';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: c.querySelector('.title')?.textContent || '',
|
||||||
|
id: idFromAttr || idFromText,
|
||||||
|
poster: c.querySelector('img')?.src || ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const movies = Array.from(document.querySelectorAll('#moviesGrid .card')).filter(c=>c.dataset.user===username).map(c=>{
|
||||||
|
const idFromAttr = c.dataset.id || '';
|
||||||
|
let idFromText = '';
|
||||||
|
if(!idFromAttr){
|
||||||
|
const text = Array.from(c.querySelectorAll('.muted')).map(el=>el.textContent).join(' ');
|
||||||
|
const m = text.match(/MovieId:\s*(\d+)/i); idFromText = m ? m[1] : '';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: c.querySelector('.title')?.textContent || '',
|
||||||
|
id: idFromAttr || idFromText,
|
||||||
|
poster: c.querySelector('img')?.src || ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {series, movies};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openForUser(usernameDisplay){
|
||||||
|
const username = usernameDisplay.trim().toLowerCase();
|
||||||
|
const {series, movies} = gatherForUser(username);
|
||||||
|
uTitle.textContent = `Subscriptions for ${usernameDisplay.trim()}`;
|
||||||
|
renderList(uSeries, series);
|
||||||
|
renderList(uMovies, movies);
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.user').forEach(uEl=>{
|
||||||
|
uEl.style.cursor='pointer';
|
||||||
|
uEl.title='Show all subscriptions for this user';
|
||||||
|
uEl.addEventListener('click', ()=> openForUser(uEl.textContent));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.user-item').forEach(item=>{
|
||||||
|
item.addEventListener('click', ()=>{
|
||||||
|
const name = item.querySelector('div')?.textContent || '';
|
||||||
|
openForUser(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
@@ -1,9 +1,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import SettingsView, test_connection, first_run
|
from .views import SettingsView, test_connection, first_run, subscriptions_overview
|
||||||
|
|
||||||
app_name = "settingspanel"
|
app_name = "settingspanel"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", SettingsView.as_view(), name="index"),
|
path("", SettingsView.as_view(), name="index"),
|
||||||
path("test-connection/", test_connection, name="test_connection"),
|
path("test-connection/", test_connection, name="test_connection"),
|
||||||
path("setup/", first_run, name="setup"),
|
path("setup/", first_run, name="setup"),
|
||||||
|
path("subscriptions/", subscriptions_overview, name="subscriptions"),
|
||||||
]
|
]
|
||||||
|
@@ -7,6 +7,8 @@ from .models import AppSettings
|
|||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from accounts.utils import jellyfin_admin_required
|
from accounts.utils import jellyfin_admin_required
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from arr_api.models import SeriesSubscription, MovieSubscription
|
||||||
|
from django.db.models import Count
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
def needs_setup():
|
def needs_setup():
|
||||||
@@ -32,7 +34,7 @@ def first_run(request):
|
|||||||
settings.radarr_api_key = form.cleaned_data['radarr_api_key']
|
settings.radarr_api_key = form.cleaned_data['radarr_api_key']
|
||||||
settings.save()
|
settings.save()
|
||||||
|
|
||||||
messages.success(request, 'Setup erfolgreich abgeschlossen!')
|
messages.success(request, 'Setup completed successfully!')
|
||||||
return redirect('accounts:login')
|
return redirect('accounts:login')
|
||||||
else:
|
else:
|
||||||
form = FirstRunSetupForm()
|
form = FirstRunSetupForm()
|
||||||
@@ -53,9 +55,9 @@ def test_connection(request):
|
|||||||
url = (request.GET.get("url") or "").strip()
|
url = (request.GET.get("url") or "").strip()
|
||||||
key = (request.GET.get("key") or "").strip()
|
key = (request.GET.get("key") or "").strip()
|
||||||
if kind not in ("sonarr", "radarr"):
|
if kind not in ("sonarr", "radarr"):
|
||||||
return JsonResponse({"ok": False, "error": "Ungültiger Typ"}, status=400)
|
return JsonResponse({"ok": False, "error": "Invalid type"}, status=400)
|
||||||
if not url or not key:
|
if not url or not key:
|
||||||
return JsonResponse({"ok": False, "error": "URL und API-Key erforderlich"}, status=400)
|
return JsonResponse({"ok": False, "error": "URL and API key required"}, status=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = requests.get(
|
r = requests.get(
|
||||||
@@ -111,29 +113,81 @@ class SettingsView(View):
|
|||||||
"jellyfin_form": jellyfin_form,
|
"jellyfin_form": jellyfin_form,
|
||||||
"arr_form": arr_form,
|
"arr_form": arr_form,
|
||||||
"mail_form": mail_form,
|
"mail_form": mail_form,
|
||||||
"account_form": acc_form
|
"account_form": acc_form,
|
||||||
})
|
})
|
||||||
|
|
||||||
cfg = AppSettings.current()
|
cfg = AppSettings.current()
|
||||||
|
|
||||||
# Update Jellyfin settings
|
# Update Jellyfin settings
|
||||||
cfg.jellyfin_server_url = jellyfin_form.cleaned_data["jellyfin_server_url"] or None
|
cfg.jellyfin_server_url = jellyfin_form.cleaned_data.get("jellyfin_server_url") or None
|
||||||
cfg.jellyfin_api_key = jellyfin_form.cleaned_data["jellyfin_api_key"] or None
|
cfg.jellyfin_api_key = jellyfin_form.cleaned_data.get("jellyfin_api_key") or None
|
||||||
cfg.sonarr_url = arr_form.cleaned_data["sonarr_url"] or None
|
|
||||||
cfg.sonarr_api_key = arr_form.cleaned_data["sonarr_api_key"] or None
|
|
||||||
cfg.radarr_url = arr_form.cleaned_data["radarr_url"] or None
|
|
||||||
cfg.radarr_api_key = arr_form.cleaned_data["radarr_api_key"] or None
|
|
||||||
|
|
||||||
cfg.mail_host = mail_form.cleaned_data["mail_host"] or None
|
# Update Sonarr/Radarr settings
|
||||||
cfg.mail_port = mail_form.cleaned_data["mail_port"] or None
|
cfg.sonarr_url = arr_form.cleaned_data.get("sonarr_url") or None
|
||||||
cfg.mail_secure = mail_form.cleaned_data["mail_secure"] or ""
|
cfg.sonarr_api_key = arr_form.cleaned_data.get("sonarr_api_key") or None
|
||||||
cfg.mail_user = mail_form.cleaned_data["mail_user"] or None
|
cfg.radarr_url = arr_form.cleaned_data.get("radarr_url") or None
|
||||||
cfg.mail_password = mail_form.cleaned_data["mail_password"] or None
|
cfg.radarr_api_key = arr_form.cleaned_data.get("radarr_api_key") or None
|
||||||
cfg.mail_from = mail_form.cleaned_data["mail_from"] or None
|
|
||||||
|
|
||||||
cfg.acc_username = acc_form.cleaned_data["username"] or None
|
# Update Mail settings
|
||||||
cfg.acc_email = acc_form.cleaned_data["email"] or None
|
cfg.mail_host = mail_form.cleaned_data.get("mail_host") or None
|
||||||
|
cfg.mail_port = mail_form.cleaned_data.get("mail_port") or None
|
||||||
|
cfg.mail_secure = mail_form.cleaned_data.get("mail_secure") or ""
|
||||||
|
cfg.mail_user = mail_form.cleaned_data.get("mail_user") or None
|
||||||
|
cfg.mail_password = mail_form.cleaned_data.get("mail_password") or None
|
||||||
|
cfg.mail_from = mail_form.cleaned_data.get("mail_from") or None
|
||||||
|
|
||||||
|
# Update account settings
|
||||||
|
cfg.acc_username = acc_form.cleaned_data.get("username") or None
|
||||||
|
cfg.acc_email = acc_form.cleaned_data.get("email") or None
|
||||||
|
|
||||||
cfg.save()
|
cfg.save()
|
||||||
messages.success(request, "Einstellungen gespeichert (DB).")
|
messages.success(request, "Settings saved (DB).")
|
||||||
return redirect("settingspanel:index")
|
return redirect("settingspanel:index")
|
||||||
|
|
||||||
|
@jellyfin_admin_required
|
||||||
|
def subscriptions_overview(request):
|
||||||
|
series = SeriesSubscription.objects.select_related('user').order_by('user__username', 'series_title')
|
||||||
|
movies = MovieSubscription.objects.select_related('user').order_by('user__username', 'title')
|
||||||
|
|
||||||
|
# Aggregate counts per user
|
||||||
|
s_counts = SeriesSubscription.objects.values('user_id', 'user__username').annotate(series_count=Count('id'))
|
||||||
|
m_counts = MovieSubscription.objects.values('user_id', 'user__username').annotate(movie_count=Count('id'))
|
||||||
|
|
||||||
|
user_map = {}
|
||||||
|
for row in s_counts:
|
||||||
|
key = row['user_id']
|
||||||
|
user_map.setdefault(key, {
|
||||||
|
'user_id': key,
|
||||||
|
'username': row['user__username'],
|
||||||
|
'series_count': 0,
|
||||||
|
'movie_count': 0,
|
||||||
|
})
|
||||||
|
user_map[key]['series_count'] = row['series_count']
|
||||||
|
for row in m_counts:
|
||||||
|
key = row['user_id']
|
||||||
|
user_map.setdefault(key, {
|
||||||
|
'user_id': key,
|
||||||
|
'username': row['user__username'],
|
||||||
|
'series_count': 0,
|
||||||
|
'movie_count': 0,
|
||||||
|
})
|
||||||
|
user_map[key]['movie_count'] = row['movie_count']
|
||||||
|
|
||||||
|
user_stats = []
|
||||||
|
for key, val in user_map.items():
|
||||||
|
total = (val.get('series_count') or 0) + (val.get('movie_count') or 0)
|
||||||
|
user_stats.append({
|
||||||
|
'user_id': val['user_id'],
|
||||||
|
'username': val['username'],
|
||||||
|
'username_lower': (val['username'] or '').lower(),
|
||||||
|
'series_count': val.get('series_count') or 0,
|
||||||
|
'movie_count': val.get('movie_count') or 0,
|
||||||
|
'total_count': total,
|
||||||
|
})
|
||||||
|
user_stats.sort(key=lambda x: (-x['total_count'], x['username'].lower()))
|
||||||
|
|
||||||
|
return render(request, 'settingspanel/subscriptions.html', {
|
||||||
|
'series': series,
|
||||||
|
'movies': movies,
|
||||||
|
'user_stats': user_stats,
|
||||||
|
})
|
||||||
|
@@ -120,6 +120,27 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-toggle {
|
||||||
|
display: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--panel-b);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nav-toggle span { display:block; width:20px; height:2px; background: var(--text); margin:4px 0; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-content { flex-wrap: wrap; gap: 10px; }
|
||||||
|
.nav-toggle { display: inline-block; }
|
||||||
|
.nav-links { display: none; width: 100%; flex-direction: column; align-items: stretch; gap: 6px; }
|
||||||
|
.nav-links.open { display: flex; }
|
||||||
|
.nav-links a, .nav-links button, .nav-links .inline-form { width: 100%; }
|
||||||
|
.nav-links a, .nav-links button { justify-content: flex-start; padding: 10px 12px; border: 1px solid var(--panel-b); border-radius: 10px; }
|
||||||
|
.user-info { order: -1; margin: 0 0 4px 0; }
|
||||||
|
}
|
||||||
|
|
||||||
.nav-links a,
|
.nav-links a,
|
||||||
.nav-links button {
|
.nav-links button {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -151,9 +172,9 @@ body {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-logout {
|
.nav-logout { color: #ef4444 !important; }
|
||||||
color: #ef4444 !important;
|
.btn-link { background: transparent; border: 1px solid var(--panel-b); border-radius: 8px; }
|
||||||
}
|
.btn-link:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
|
||||||
.nav-admin {
|
.nav-admin {
|
||||||
color: var(--accent) !important;
|
color: var(--accent) !important;
|
||||||
|
@@ -39,6 +39,14 @@ h1 {
|
|||||||
margin-bottom: 16px
|
margin-bottom: 16px
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.controls { flex-direction: column; align-items: stretch; }
|
||||||
|
.controls form { width: 100%; }
|
||||||
|
.controls .seg { width: 100%; justify-content: space-between; }
|
||||||
|
.controls-actions { width: 100%; }
|
||||||
|
.controls-actions .btn { width: 100%; justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
.controls form {
|
.controls form {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -67,6 +75,24 @@ h1 {
|
|||||||
cursor: pointer
|
cursor: pointer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Calendar button */
|
||||||
|
.controls-actions .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #2a2b44;
|
||||||
|
background: linear-gradient(180deg, #19213b 0%, #141a30 100%);
|
||||||
|
color: #e6e6e6;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 650;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,.25);
|
||||||
|
transition: transform .08s ease, filter .12s ease, border-color .12s ease;
|
||||||
|
}
|
||||||
|
.controls-actions .btn:hover { filter: brightness(1.08); border-color: #3b4aa0; transform: translateY(-1px); }
|
||||||
|
.controls-actions .btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
.seg {
|
.seg {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background: #0f0f17;
|
background: #0f0f17;
|
||||||
@@ -99,6 +125,10 @@ h1 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid { grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--panel-b);
|
border: 1px solid var(--panel-b);
|
||||||
@@ -111,6 +141,12 @@ h1 {
|
|||||||
cursor: pointer
|
cursor: pointer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.card { gap: 10px; }
|
||||||
|
.poster { width: 90px; height: 135px; }
|
||||||
|
.title { font-size: .98rem; }
|
||||||
|
}
|
||||||
|
|
||||||
.card:active,
|
.card:active,
|
||||||
.card:hover {
|
.card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@@ -260,6 +296,12 @@ h1 {
|
|||||||
border-bottom: 1px solid #20223a
|
border-bottom: 1px solid #20223a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modal-header { grid-template-columns: 90px 1fr 36px; gap: 10px; }
|
||||||
|
.m-poster { width: 90px; height: 135px; }
|
||||||
|
.m-title { font-size: 1.05rem; }
|
||||||
|
}
|
||||||
|
|
||||||
.m-poster {
|
.m-poster {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -344,6 +386,10 @@ h1 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modal-body { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
.section-block {
|
.section-block {
|
||||||
background: #101327;
|
background: #101327;
|
||||||
border: 1px solid #20223a;
|
border: 1px solid #20223a;
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@@ -14,15 +14,18 @@
|
|||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
<div class="nav-content">
|
<div class="nav-content">
|
||||||
<a href="/" class="nav-brand">Subscribarr</a>
|
<a href="/" class="nav-brand">Subscribarr</a>
|
||||||
<div class="nav-links">
|
<button class="nav-toggle" aria-label="Open menu" aria-expanded="false" aria-controls="nav-menu">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
<div id="nav-menu" class="nav-links">
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<span class="user-info">Angemeldet als <strong>{{ user.username }}</strong></span>
|
<span class="user-info">Signed in as <strong>{{ user.username }}</strong></span>
|
||||||
<a href="{% url 'accounts:profile' %}" class="nav-profile">
|
<a href="{% url 'accounts:profile' %}" class="nav-profile">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
Profil
|
Profile
|
||||||
</a>
|
</a>
|
||||||
{% if user.is_jellyfin_admin %}
|
{% if user.is_jellyfin_admin %}
|
||||||
<a href="{% url 'settingspanel:index' %}" class="nav-admin">
|
<a href="{% url 'settingspanel:index' %}" class="nav-admin">
|
||||||
@@ -34,14 +37,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{% url 'accounts:logout' %}" class="inline-form">
|
<form method="post" action="{% url 'accounts:logout' %}" class="inline-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="nav-logout">
|
<button type="submit" class="nav-logout btn-link">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
stroke-width="2">
|
stroke-width="2">
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||||
<polyline points="16 17 21 12 16 7"></polyline>
|
<polyline points="16 17 21 12 16 7"></polyline>
|
||||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||||
</svg>
|
</svg>
|
||||||
Abmelden
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -51,7 +54,7 @@
|
|||||||
<polyline points="10 17 15 12 10 7"></polyline>
|
<polyline points="10 17 15 12 10 7"></polyline>
|
||||||
<line x1="15" y1="12" x2="3" y2="12"></line>
|
<line x1="15" y1="12" x2="3" y2="12"></line>
|
||||||
</svg>
|
</svg>
|
||||||
Anmelden
|
Sign in
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'accounts:register' %}" class="nav-register">
|
<a href="{% url 'accounts:register' %}" class="nav-register">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -60,13 +63,25 @@
|
|||||||
<line x1="20" y1="8" x2="20" y2="14"></line>
|
<line x1="20" y1="8" x2="20" y2="14"></line>
|
||||||
<line x1="23" y1="11" x2="17" y2="11"></line>
|
<line x1="23" y1="11" x2="17" y2="11"></line>
|
||||||
</svg>
|
</svg>
|
||||||
Registrieren
|
Register
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const btn = document.querySelector('.nav-toggle');
|
||||||
|
const menu = document.getElementById('nav-menu');
|
||||||
|
if(!btn || !menu) return;
|
||||||
|
btn.addEventListener('click', ()=>{
|
||||||
|
const open = menu.classList.toggle('open');
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user