usermanagement/translation/calendar
This commit is contained in:
		| @@ -10,15 +10,15 @@ class CustomUserCreationForm(UserCreationForm): | ||||
|         fields = ('username', 'email', 'password1', 'password2') | ||||
|  | ||||
| class CustomUserChangeForm(UserChangeForm): | ||||
|     password = None  # Passwort-Änderung über extra Formular | ||||
|     password = None  # Password change via separate form | ||||
|      | ||||
|     class Meta: | ||||
|         model = User | ||||
|         fields = ('email',) | ||||
|         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): | ||||
|     username = forms.CharField(label='Benutzername', widget=forms.TextInput(attrs={'class': 'form-control'})) | ||||
|     password = forms.CharField(label='Passwort', widget=forms.PasswordInput(attrs={'class': 'form-control'})) | ||||
|     username = forms.CharField(label='Username', widget=forms.TextInput(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): | ||||
|     """ | ||||
|     Custom User Model mit zusätzlichen Feldern und Berechtigungen. | ||||
|     Normale User können nur ihre eigenen Daten bearbeiten. | ||||
|     Admin-User können alles. | ||||
|     Custom User Model with additional fields and permissions. | ||||
|     Regular users can only edit their own data. | ||||
|     Admin users can edit everything. | ||||
|     """ | ||||
|     email = models.EmailField(_("email address"), unique=True) | ||||
|     bio = models.TextField(max_length=500, blank=True) | ||||
| @@ -26,7 +26,7 @@ class User(AbstractUser): | ||||
|             client = JellyfinClient() | ||||
|             return client.is_admin(self.jellyfin_user_id, self.jellyfin_token) | ||||
|         except: | ||||
|             # Im Fehlerfall den lokalen Status verwenden | ||||
|             # On error, fall back to local status | ||||
|             return self.is_admin | ||||
|              | ||||
|     @property  | ||||
|   | ||||
| @@ -2,14 +2,14 @@ | ||||
|  | ||||
| {% block content %} | ||||
| <div class="auth-container"> | ||||
|     <h2>Anmelden</h2> | ||||
|     <h2>Sign in</h2> | ||||
|     <form method="post" class="auth-form"> | ||||
|         {% csrf_token %} | ||||
|         {{ form.as_p }} | ||||
|         <button type="submit" class="btn-primary">Anmelden</button> | ||||
|     <button type="submit" class="btn-primary">Sign in</button> | ||||
|     </form> | ||||
|     <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> | ||||
| {% endblock %} | ||||
| @@ -2,11 +2,11 @@ | ||||
|  | ||||
| {% block content %} | ||||
| <div class="auth-container"> | ||||
|     <h2>Passwort ändern</h2> | ||||
|     <h2>Change password</h2> | ||||
|     <form method="post" class="auth-form"> | ||||
|         {% csrf_token %} | ||||
|         {{ form.as_p }} | ||||
|         <button type="submit" class="btn-primary">Passwort ändern</button> | ||||
|     <button type="submit" class="btn-primary">Change password</button> | ||||
|     </form> | ||||
| </div> | ||||
| {% endblock %} | ||||
| @@ -2,8 +2,8 @@ | ||||
|  | ||||
| {% block content %} | ||||
| <div class="auth-container"> | ||||
|     <h2>Passwort geändert</h2> | ||||
|     <p>Ihr Passwort wurde erfolgreich geändert.</p> | ||||
|     <p><a href="{% url 'accounts:profile' %}">Zurück zum Profil</a></p> | ||||
|     <h2>Password changed</h2> | ||||
|     <p>Your password has been changed successfully.</p> | ||||
|     <p><a href="{% url 'accounts:profile' %}">Back to profile</a></p> | ||||
| </div> | ||||
| {% endblock %} | ||||
| @@ -7,7 +7,7 @@ | ||||
|  | ||||
| {% block content %} | ||||
| <div class="profile-container"> | ||||
|     <h2>Hallo, {{ user.username }}</h2> | ||||
|     <h2>Hello, {{ user.username }}</h2> | ||||
|  | ||||
|     {% if messages %} | ||||
|     <div class="messages"> | ||||
| @@ -18,24 +18,24 @@ | ||||
|     {% endif %} | ||||
|  | ||||
|     <div class="profile-section"> | ||||
|         <h3>E-Mail-Adresse</h3> | ||||
|     <h3>Email address</h3> | ||||
|         <form method="post" class="profile-form compact-form"> | ||||
|             {% csrf_token %} | ||||
|             <div class="form-row"> | ||||
|                 <label for="id_email">E-Mail</label> | ||||
|                 <label for="id_email">Email</label> | ||||
|                 {{ form.email }} | ||||
|             </div> | ||||
|             <button type="submit" class="btn-primary">Speichern</button> | ||||
|             <button type="submit" class="btn-primary">Save</button> | ||||
|         </form> | ||||
|  | ||||
|         {% if user.jellyfin_server %} | ||||
|         <div class="jellyfin-info"> | ||||
|             <h4>Jellyfin-Verbindung</h4> | ||||
|             <h4>Jellyfin connection</h4> | ||||
|             <p> | ||||
|                 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 %} | ||||
|                 <span class="badge badge-admin">Jellyfin Administrator</span> | ||||
|                 <span class="badge badge-admin">Jellyfin administrator</span> | ||||
|                 {% endif %} | ||||
|             </p> | ||||
|         </div> | ||||
| @@ -43,9 +43,9 @@ | ||||
|     </div> | ||||
|  | ||||
|     <div class="profile-section"> | ||||
|         <h3>Meine Abonnements</h3> | ||||
|     <h3>My subscriptions</h3> | ||||
|  | ||||
|         <h4>Serien</h4> | ||||
|     <h4>Series</h4> | ||||
|         {% if series_subs %} | ||||
|         <div class="subscription-list"> | ||||
|             {% for sub in series_subs %} | ||||
| @@ -53,11 +53,11 @@ | ||||
|                 {% if sub.series_poster %} | ||||
|                 <img src="{{ sub.series_poster }}" alt="{{ sub.series_title }}" class="subscription-poster"> | ||||
|                 {% 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 %} | ||||
|                 <div class="subscription-info"> | ||||
|                     <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 %} | ||||
|                     <div class="subscription-overview">{{ sub.series_overview|truncatechars:100 }}</div> | ||||
|                     {% endif %} | ||||
| @@ -66,10 +66,10 @@ | ||||
|             {% endfor %} | ||||
|         </div> | ||||
|         {% else %} | ||||
|         <p class="muted">Keine Serien abonniert.</p> | ||||
|     <p class="muted">No series subscribed.</p> | ||||
|         {% endif %} | ||||
|  | ||||
|         <h4>Filme</h4> | ||||
|     <h4>Movies</h4> | ||||
|         {% if movie_subs %} | ||||
|         <div class="subscription-list"> | ||||
|             {% for sub in movie_subs %} | ||||
| @@ -77,11 +77,11 @@ | ||||
|                 {% if sub.poster %} | ||||
|                 <img src="{{ sub.poster }}" alt="{{ sub.title }}" class="subscription-poster"> | ||||
|                 {% 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 %} | ||||
|                 <div class="subscription-info"> | ||||
|                     <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 %} | ||||
|                     <div class="subscription-overview">{{ sub.overview|truncatechars:100 }}</div> | ||||
|                     {% endif %} | ||||
| @@ -90,7 +90,7 @@ | ||||
|             {% endfor %} | ||||
|         </div> | ||||
|         {% else %} | ||||
|         <p class="muted">Keine Filme abonniert.</p> | ||||
|     <p class="muted">No movies subscribed.</p> | ||||
|         {% endif %} | ||||
|     </div> | ||||
| </div> | ||||
|   | ||||
| @@ -2,14 +2,14 @@ | ||||
|  | ||||
| {% block content %} | ||||
| <div class="auth-container"> | ||||
|     <h2>Registrieren</h2> | ||||
|     <h2>Register</h2> | ||||
|     <form method="post" class="auth-form"> | ||||
|         {% csrf_token %} | ||||
|         {{ form.as_p }} | ||||
|         <button type="submit" class="btn-primary">Registrieren</button> | ||||
|     <button type="submit" class="btn-primary">Register</button> | ||||
|     </form> | ||||
|     <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> | ||||
| {% endblock %} | ||||
| @@ -7,7 +7,7 @@ from django.contrib import messages | ||||
|  | ||||
| class JellyfinClient: | ||||
|     def __init__(self): | ||||
|         # Basis-Einstellungen aus den Django-Settings | ||||
|     # Base settings from Django settings | ||||
|         self.client = settings.JELLYFIN_CLIENT | ||||
|         self.version = settings.JELLYFIN_VERSION | ||||
|         self.device = settings.JELLYFIN_DEVICE | ||||
| @@ -18,13 +18,13 @@ class JellyfinClient: | ||||
|     def authenticate(self, username, password): | ||||
|         """Authenticate with Jellyfin and return user info if successful""" | ||||
|         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://')): | ||||
|             self.server_url = f'http://{self.server_url}' | ||||
|          | ||||
|         # Entferne trailing slashes | ||||
|     # Remove trailing slashes | ||||
|         self.server_url = self.server_url.rstrip('/') | ||||
|  | ||||
|         headers = { | ||||
| @@ -57,13 +57,13 @@ class JellyfinClient: | ||||
|                 'is_admin': data['User'].get('Policy', {}).get('IsAdministrator', False) | ||||
|             } | ||||
|         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: | ||||
|             raise ValueError("Zeitüberschreitung bei der Verbindung zum Server.") | ||||
|             raise ValueError("Connection to the server timed out.") | ||||
|         except requests.exceptions.HTTPError as e: | ||||
|             if e.response.status_code == 401: | ||||
|                 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: | ||||
|             return None | ||||
|  | ||||
| @@ -106,11 +106,11 @@ def jellyfin_admin_required(view_func): | ||||
|     @wraps(view_func) | ||||
|     def _wrapped_view(request, *args, **kwargs): | ||||
|         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') | ||||
|          | ||||
|         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 view_func(request, *args, **kwargs) | ||||
|   | ||||
| @@ -17,7 +17,7 @@ class RegisterView(CreateView): | ||||
|      | ||||
|     def form_valid(self, 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 | ||||
|  | ||||
| @login_required | ||||
| @@ -26,12 +26,12 @@ def profile(request): | ||||
|         form = CustomUserChangeForm(request.POST, instance=request.user) | ||||
|         if form.is_valid(): | ||||
|             form.save() | ||||
|             messages.success(request, 'E-Mail gespeichert.') | ||||
|             messages.success(request, 'Email saved.') | ||||
|             return redirect('accounts:profile') | ||||
|     else: | ||||
|         form = CustomUserChangeForm(instance=request.user) | ||||
|  | ||||
|     # Lade Abonnements | ||||
|     # Load subscriptions | ||||
|     series_subs = request.user.series_subscriptions.all() | ||||
|     movie_subs = request.user.movie_subscriptions.all() | ||||
|  | ||||
| @@ -40,7 +40,7 @@ def profile(request): | ||||
|         from settingspanel.models import AppSettings | ||||
|         from arr_api.services import sonarr_get_series, radarr_lookup_movie_by_title | ||||
|         cfg = AppSettings.current() | ||||
|         # Serien | ||||
|     # Series | ||||
|         for sub in series_subs: | ||||
|             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) | ||||
| @@ -51,7 +51,7 @@ def profile(request): | ||||
|                     if not sub.series_genres: | ||||
|                         sub.series_genres = details.get('series_genres') or [] | ||||
|                     sub.save(update_fields=['series_poster', 'series_overview', 'series_genres']) | ||||
|         # Filme | ||||
|     # Movies | ||||
|         for sub in movie_subs: | ||||
|             if not sub.poster: | ||||
|                 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() | ||||
|             server_url = app_settings.get_jellyfin_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}) | ||||
|  | ||||
|             try: | ||||
| @@ -93,7 +93,7 @@ def jellyfin_login(request): | ||||
|                 auth_result = client.authenticate(username, password) | ||||
|                  | ||||
|                 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}) | ||||
|  | ||||
|                 # Existierenden User finden oder neu erstellen | ||||
| @@ -116,13 +116,13 @@ def jellyfin_login(request): | ||||
|                     user.save() | ||||
|  | ||||
|                 login(request, user) | ||||
|                 messages.success(request, f'Willkommen, {username}!') | ||||
|                 messages.success(request, f'Welcome, {username}!') | ||||
|                 return redirect('arr_api:index') | ||||
|                      | ||||
|             except ValueError as e: | ||||
|                 messages.error(request, str(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 | ||||
|         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 | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = 'Prüft neue Medien und sendet Benachrichtigungen' | ||||
|     help = 'Checks for new media and sends notifications' | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         self.stdout.write(f'[{timezone.now()}] Starte Medien-Check...') | ||||
|     self.stdout.write(f'[{timezone.now()}] Starting media check...') | ||||
|         try: | ||||
|             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: | ||||
|             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) | ||||
|  | ||||
|     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): | ||||
|         return self.series_title | ||||
| @@ -29,18 +29,16 @@ class MovieSubscription(models.Model): | ||||
|     updated_at = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|     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): | ||||
|         return self.title | ||||
|  | ||||
| class SentNotification(models.Model): | ||||
|     """ | ||||
|     Speichert gesendete Benachrichtigungen um Duplikate zu vermeiden | ||||
|     """ | ||||
|     """Store sent notifications to avoid duplicates""" | ||||
|     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) | ||||
|     media_id = models.IntegerField() | ||||
|     media_type = models.CharField(max_length=10)  # 'series' oder 'movie' | ||||
|     media_type = models.CharField(max_length=10)  # 'series' or 'movie' | ||||
|     media_title = models.CharField(max_length=255) | ||||
|     air_date = models.DateField() | ||||
|     sent_at = models.DateTimeField(auto_now_add=True) | ||||
|   | ||||
| @@ -68,7 +68,7 @@ def send_notification_email( | ||||
|     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() | ||||
|     logger.info( | ||||
| @@ -94,7 +94,7 @@ def send_notification_email( | ||||
|     context = { | ||||
|         'username': user.username, | ||||
|         'title': media_title, | ||||
|         'type': 'Serie' if media_type == 'series' else 'Film', | ||||
|     'type': 'Series' if media_type == 'series' else 'Movie', | ||||
|         'overview': overview, | ||||
|         'poster_url': poster_url, | ||||
|         'episode_title': episode_title, | ||||
| @@ -105,7 +105,7 @@ def send_notification_email( | ||||
|         '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) | ||||
|  | ||||
|     send_mail( | ||||
| @@ -209,8 +209,8 @@ def get_todays_radarr_calendar(): | ||||
|  | ||||
| def check_jellyfin_availability(user, media_id, media_type): | ||||
|     """ | ||||
|     Ersetzt: Wir prüfen Verfügbarkeit über Sonarr/Radarr (hasFile), | ||||
|     was zuverlässig ist, wenn Jellyfin dieselben Ordner scannt. | ||||
|     Replaced: We check availability via Sonarr/Radarr (hasFile), | ||||
|     which is reliable if Jellyfin scans the same folders. | ||||
|     """ | ||||
|     # user is unused here; kept for backward compatibility | ||||
|     if media_type == 'series': | ||||
|   | ||||
							
								
								
									
										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" %} | ||||
| {% load static %} | ||||
|  | ||||
| {% block title %}Subscribarr – Übersicht{% endblock %} | ||||
| {% block title %}Subscribarr – Overview{% endblock %} | ||||
|  | ||||
| {% block extra_style %} | ||||
| <link rel="stylesheet" href="{% static 'css/index.css' %}"> | ||||
| @@ -20,26 +20,30 @@ | ||||
|     <div class="controls"> | ||||
|         <form method="get" class="controls-form"> | ||||
|             <input type="hidden" name="kind" value="{{ kind|default:'all' }}"> | ||||
|             <input type="text" name="q" placeholder="Suche Serien/Filme…" value="{{ query|default:'' }}"> | ||||
|             <input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Zeitraum in Tagen"> | ||||
|             <button type="submit">Suchen</button> | ||||
|             <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="Time range (days)"> | ||||
|             <button type="submit">Search</button> | ||||
|         </form> | ||||
|  | ||||
|         <nav class="seg" aria-label="Typ filtern"> | ||||
|         <nav class="seg" aria-label="Filter type"> | ||||
|             {% with qs=query|urlencode %} | ||||
|             <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 %}" | ||||
|                 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 %}" | ||||
|                 class="{% if kind == 'movies' %}active{% endif %}">Filme</a> | ||||
|                 class="{% if kind == 'movies' %}active{% endif %}">Movies</a> | ||||
|             {% endwith %} | ||||
|         </nav> | ||||
|  | ||||
|         <div class="controls-actions"> | ||||
|             <a href="/calendar/" class="btn btn-accent" title="Open calendar">📅 Calendar</a> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     {% if show_series %} | ||||
|     <div class="section"> | ||||
|         <h2>Laufende Serien</h2> | ||||
|     <h2>Ongoing series</h2> | ||||
|         <div class="grid"> | ||||
|             {% for s in series_grouped %} | ||||
|             <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> | ||||
|                         </div> | ||||
|                         {% empty %} | ||||
|                         <div class="muted">Keine kommenden Episoden.</div> | ||||
|                         <div class="muted">No upcoming episodes.</div> | ||||
|                         {% endfor %} | ||||
|                     </div> | ||||
|                 </div> | ||||
| @@ -72,7 +76,7 @@ | ||||
|                 {% endwith %} | ||||
|             </div> | ||||
|             {% empty %} | ||||
|             <p class="muted">Keine Serien gefunden.</p> | ||||
|             <p class="muted">No series found.</p> | ||||
|             {% endfor %} | ||||
|         </div> | ||||
|     </div> | ||||
| @@ -80,7 +84,7 @@ | ||||
|  | ||||
|     {% if show_movies %} | ||||
|     <div class="section"> | ||||
|         <h2>Anstehende Filme</h2> | ||||
|     <h2>Upcoming movies</h2> | ||||
|         <div class="grid"> | ||||
|             {% for m in movies %} | ||||
|             <div class="movie-card" data-kind="movie" data-title="{{ m.title|escape }}" | ||||
| @@ -92,13 +96,13 @@ | ||||
|                 {% endif %} | ||||
|                 <div class="title">{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}</div> | ||||
|                 <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.physicalRelease %}<br>Disc: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %} | ||||
|                     {% if m.physicalRelease %}<br>Physical: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %} | ||||
|                 </div> | ||||
|             </div> | ||||
|             {% empty %} | ||||
|             <p class="muted">Keine Filme gefunden.</p> | ||||
|             <p class="muted">No movies found.</p> | ||||
|             {% endfor %} | ||||
|         </div> | ||||
|     </div> | ||||
| @@ -118,17 +122,17 @@ | ||||
|                 <div id="mSub" class="m-sub"></div> | ||||
|                 <div id="mBadges" class="badges"></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 class="modal-body"> | ||||
|             <div class="section-block"> | ||||
|                 <div class="section-title">Beschreibung</div> | ||||
|                 <div class="section-title">Overview</div> | ||||
|                 <div id="mOverview" class="desc muted"></div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="section-block"> | ||||
|                 <div class="section-title">Kommende Episoden</div> | ||||
|                 <div class="section-title">Upcoming episodes</div> | ||||
|                 <div class="section-divider"></div> | ||||
|                 <div id="mEpisodes"></div> | ||||
|             </div> | ||||
| @@ -153,6 +157,7 @@ | ||||
|         const mSub = $("#mSub"); | ||||
|         const epSection = mEpisodes.closest(".section-block"); | ||||
|         const subscribeBtn = $("#subscribeBtn"); | ||||
|     const bc = ('BroadcastChannel' in window) ? new BroadcastChannel('subscribarr-sub') : null; | ||||
|  | ||||
|         let lastClickedCard = null; | ||||
|  | ||||
| @@ -178,7 +183,7 @@ | ||||
|             return "movie:" + (card.dataset.title || ""); | ||||
|         } | ||||
|  | ||||
|         // Cache für Abonnement-Status | ||||
|     // Cache for subscription state | ||||
|         const subCache = new Map(); | ||||
|  | ||||
|         async function loadAllSubs() { | ||||
| @@ -221,45 +226,48 @@ | ||||
|                 }); | ||||
|  | ||||
|                 if (resp.status === 403) { | ||||
|                     // Nicht eingeloggt | ||||
|                     // Not logged in | ||||
|                     window.location.href = '/accounts/login/?next=' + encodeURIComponent(window.location.pathname); | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 if (!resp.ok) throw new Error(`HTTP ${resp.status}`); | ||||
|  | ||||
|                 // Cache aktualisieren | ||||
|                 // Update cache | ||||
|                 if (on) { | ||||
|                     subCache.set(k, true); | ||||
|                 } else { | ||||
|                     subCache.delete(k); | ||||
|                 } | ||||
|  | ||||
|                 // Cross-tab/page notify | ||||
|                 if (bc) bc.postMessage({ type: 'sub_change', kind: type, key: id, on }); | ||||
|             } catch (err) { | ||||
|                 console.error("Failed to update subscription:", err); | ||||
|                 // Cache-Update rückgängig machen bei Fehler | ||||
|                 // Revert optimistic cache on error | ||||
|                 if (on) { | ||||
|                     subCache.delete(k); | ||||
|                 } else { | ||||
|                     subCache.set(k, true); | ||||
|                 } | ||||
|  | ||||
|                 // Fehlermeldung anzeigen | ||||
|                 // Show error | ||||
|                 const errorMsg = document.createElement('div'); | ||||
|                 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); | ||||
|                 setTimeout(() => errorMsg.remove(), 3000); | ||||
|             } | ||||
|         } | ||||
|         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) { | ||||
|                 subscribeBtn.textContent = on ? "Unsubscribe" : "Subscribe"; | ||||
|                 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 () => { | ||||
|             await loadAllSubs(); | ||||
|             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.addEventListener("click", () => { | ||||
|                 lastClickedCard = card; | ||||
| @@ -278,22 +309,22 @@ | ||||
|                 const poster = card.dataset.poster || ""; | ||||
|                 const overview = card.dataset.overview || ""; | ||||
|  | ||||
|                 // Episoden aus eingebettetem JSON <script id="eps-<id>"> | ||||
|                 // Episodes from embedded JSON <script id="eps-<id>"> | ||||
|                 let episodes = []; | ||||
|                 const script = document.getElementById("eps-" + id); | ||||
|                 if (script) { try { episodes = JSON.parse(script.textContent); } catch { } } | ||||
|  | ||||
|                 // Modal befüllen | ||||
|                 // Fill modal | ||||
|                 mTitle.textContent = title; | ||||
|                 mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster"; | ||||
|                 mPoster.alt = title; | ||||
|                 mOverview.textContent = overview || "Keine Beschreibung verfügbar."; | ||||
|                 mOverview.textContent = overview || "No overview available."; | ||||
|  | ||||
|                 mSub.textContent = episodes.length | ||||
|                     ? `${episodes.length} kommende Episode(n)` | ||||
|                     : "Keine kommenden Episoden"; | ||||
|                     ? `${episodes.length} upcoming episode(s)` | ||||
|                     : "No upcoming episodes"; | ||||
|  | ||||
|                 // Genres-Badges, falls data-genres vorhanden | ||||
|                 // Genre badges if data-genres is present | ||||
|                 mBadges.innerHTML = ""; | ||||
|                 if (card.dataset.genres) { | ||||
|                     card.dataset.genres.split(",").map(s => s.trim()).filter(Boolean).forEach(g => { | ||||
| @@ -304,7 +335,7 @@ | ||||
|                     }); | ||||
|                 } | ||||
|  | ||||
|                 // Episodenbereich | ||||
|                 // Episodes section | ||||
|                 epSection.style.display = ""; | ||||
|                 mEpisodes.innerHTML = ""; | ||||
|                 if (!episodes.length) { | ||||
| @@ -323,10 +354,10 @@ | ||||
|                     }); | ||||
|                 } | ||||
|  | ||||
|                 // Subscribe-UI für diese Karte setzen | ||||
|                 // Set subscribe UI for this card | ||||
|                 applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|  | ||||
|                 // Status nochmal aktualisieren zur Sicherheit | ||||
|                 // Refresh status again for safety | ||||
|                 loadAllSubs().then(() => { | ||||
|                     applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|                 }); | ||||
| @@ -335,7 +366,7 @@ | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         // ===== Film-Karten öffnen ===== | ||||
|     // ===== Open movie cards ===== | ||||
|         $$(".movie-card").forEach(card => { | ||||
|             card.addEventListener("click", () => { | ||||
|                 lastClickedCard = card; | ||||
| @@ -347,19 +378,19 @@ | ||||
|                 mTitle.textContent = title; | ||||
|                 mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster"; | ||||
|                 mPoster.alt = title; | ||||
|                 mOverview.textContent = overview || "Keine Beschreibung verfügbar."; | ||||
|                 mOverview.textContent = overview || "No overview available."; | ||||
|  | ||||
|                 mSub.textContent = ""; | ||||
|                 mBadges.innerHTML = ""; | ||||
|  | ||||
|                 // Episodenbereich ausblenden | ||||
|                 // Hide episodes section | ||||
|                 epSection.style.display = "none"; | ||||
|                 mEpisodes.innerHTML = ""; | ||||
|  | ||||
|                 // Subscribe-UI für diese Karte setzen | ||||
|                 // Set subscribe UI for this card | ||||
|                 applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|  | ||||
|                 // Status nochmal aktualisieren zur Sicherheit | ||||
|                 // Refresh status again for safety | ||||
|                 loadAllSubs().then(() => { | ||||
|                     applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|                 }); | ||||
| @@ -368,7 +399,7 @@ | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         // ===== Subscribe-Button im Modal mit Backend-Sync ===== | ||||
|     // ===== Subscribe button in modal with backend sync ===== | ||||
|         if (subscribeBtn) { | ||||
|             subscribeBtn.addEventListener("click", async () => { | ||||
|                 if (!lastClickedCard) return; | ||||
| @@ -378,16 +409,16 @@ | ||||
|                 // Optimistic UI update | ||||
|                 applySubUI(lastClickedCard, newState); | ||||
|  | ||||
|                 // Backend-Sync | ||||
|                 // Backend sync | ||||
|                 await saveSub(lastClickedCard, newState); | ||||
|  | ||||
|                 // Status neu laden zur Sicherheit | ||||
|                 // Refresh status again for safety | ||||
|                 const finalState = await loadSub(lastClickedCard); | ||||
|                 applySubUI(lastClickedCard, finalState); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // ===== Datumsangaben in der Übersicht formatieren ===== | ||||
|     // ===== Format date/time labels in the overview ===== | ||||
|         document.querySelectorAll("[data-dt]").forEach(el => { | ||||
|             const v = el.getAttribute("data-dt"); | ||||
|             if (!v) return; | ||||
|   | ||||
| @@ -2,13 +2,17 @@ from django.urls import path | ||||
| from .views import ( | ||||
|     ArrIndexView, SeriesSubscribeView, SeriesUnsubscribeView, | ||||
|     MovieSubscribeView, MovieUnsubscribeView, | ||||
|     ListSeriesSubscriptionsView, ListMovieSubscriptionsView | ||||
|     ListSeriesSubscriptionsView, ListMovieSubscriptionsView, | ||||
|     CalendarView, CalendarEventsApi, | ||||
| ) | ||||
|  | ||||
| app_name = 'arr_api' | ||||
|  | ||||
| urlpatterns = [ | ||||
|     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 | ||||
|     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 .services import sonarr_calendar, radarr_calendar, ArrServiceError | ||||
| from .models import SeriesSubscription, MovieSubscription | ||||
| from django.utils import timezone | ||||
|  | ||||
|  | ||||
| def _get_int(request, key, default): | ||||
| @@ -67,13 +68,13 @@ class ArrIndexView(View): | ||||
|         try: | ||||
|             eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"]) | ||||
|         except ArrServiceError as e: | ||||
|             messages.error(request, f"Sonarr nicht erreichbar: {e}") | ||||
|             messages.error(request, f"Sonarr is not reachable: {e}") | ||||
|  | ||||
|         # Radarr robust laden | ||||
|         try: | ||||
|             movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"]) | ||||
|         except ArrServiceError as e: | ||||
|             messages.error(request, f"Radarr nicht erreichbar: {e}") | ||||
|             messages.error(request, f"Radarr is not reachable: {e}") | ||||
|  | ||||
|         # Suche | ||||
|         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): | ||||
|     @method_decorator(require_POST) | ||||
|     def post(self, request, series_id): | ||||
| @@ -145,9 +214,9 @@ class SubscribeSeriesView(View): | ||||
|         ) | ||||
|          | ||||
|         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: | ||||
|             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') | ||||
|  | ||||
| @@ -157,7 +226,7 @@ class UnsubscribeSeriesView(View): | ||||
|         subscription = get_object_or_404(SeriesSubscription, series_id=series_id) | ||||
|         series_title = subscription.series_title | ||||
|         subscription.delete() | ||||
|         messages.success(request, f'Abonnement für "{series_title}" wurde beendet.') | ||||
|         messages.success(request, f'Subscription for "{series_title}" has been removed.') | ||||
|         return redirect('arr_api:index') | ||||
|  | ||||
| class SubscribeMovieView(View): | ||||
| @@ -178,9 +247,9 @@ class SubscribeMovieView(View): | ||||
|         ) | ||||
|          | ||||
|         if created: | ||||
|             messages.success(request, f'Film "{movie_data["title"]}" wurde abonniert!') | ||||
|             messages.success(request, f'Subscribed to movie "{movie_data["title"]}"!') | ||||
|         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') | ||||
|  | ||||
| @@ -190,14 +259,14 @@ class UnsubscribeMovieView(View): | ||||
|         subscription = get_object_or_404(MovieSubscription, movie_id=movie_id) | ||||
|         movie_title = subscription.title | ||||
|         subscription.delete() | ||||
|         messages.success(request, f'Abonnement für "{movie_title}" wurde beendet.') | ||||
|         messages.success(request, f'Subscription for "{movie_title}" has been removed.') | ||||
|         return redirect('arr_api:index') | ||||
|  | ||||
|  | ||||
| @require_POST | ||||
| @login_required | ||||
| def subscribe_series(request, series_id): | ||||
|     """Serie abonnieren""" | ||||
|     """Subscribe to a series""" | ||||
|     try: | ||||
|         # Existiert bereits? | ||||
|         if SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists(): | ||||
| @@ -224,7 +293,7 @@ def subscribe_series(request, series_id): | ||||
| @require_POST | ||||
| @login_required | ||||
| def unsubscribe_series(request, series_id): | ||||
|     """Serie deabonnieren""" | ||||
|     """Unsubscribe from a series""" | ||||
|     try: | ||||
|         SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete() | ||||
|         return JsonResponse({'success': True}) | ||||
| @@ -233,14 +302,14 @@ def unsubscribe_series(request, series_id): | ||||
|  | ||||
| @login_required | ||||
| 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() | ||||
|     return JsonResponse({'subscribed': is_subbed}) | ||||
|  | ||||
| @require_POST | ||||
| @login_required | ||||
| def subscribe_movie(request, movie_id): | ||||
|     """Film abonnieren""" | ||||
|     """Subscribe to a movie""" | ||||
|     try: | ||||
|         # Existiert bereits? | ||||
|         if MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists(): | ||||
| @@ -268,7 +337,7 @@ def subscribe_movie(request, movie_id): | ||||
| @require_POST | ||||
| @login_required | ||||
| def unsubscribe_movie(request, movie_id): | ||||
|     """Film deabonnieren""" | ||||
|     """Unsubscribe from a movie""" | ||||
|     try: | ||||
|         MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).delete() | ||||
|         return JsonResponse({'success': True}) | ||||
| @@ -277,13 +346,13 @@ def unsubscribe_movie(request, movie_id): | ||||
|  | ||||
| @login_required | ||||
| 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() | ||||
|     return JsonResponse({'subscribed': is_subbed}) | ||||
|  | ||||
| @login_required | ||||
| 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) | ||||
|     movies = MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True) | ||||
|     return JsonResponse({ | ||||
|   | ||||
| @@ -4,7 +4,7 @@ services: | ||||
|     build: . | ||||
|     container_name: subscribarr | ||||
|     ports: | ||||
|       - "8000:8000" | ||||
|       - "8081:8000" | ||||
|     environment: | ||||
|       # Django | ||||
|       - DJANGO_DEBUG=true | ||||
| @@ -13,22 +13,22 @@ services: | ||||
|       - DB_PATH=/app/data/db.sqlite3 | ||||
|       - NOTIFICATIONS_ALLOW_DUPLICATES=false | ||||
|       # App Settings (optional, otherwise use first-run setup) | ||||
|       - JELLYFIN_URL= | ||||
|       - JELLYFIN_API_KEY= | ||||
|       - SONARR_URL= | ||||
|       - SONARR_API_KEY= | ||||
|       - RADARR_URL= | ||||
|       - RADARR_API_KEY= | ||||
|       - MAIL_HOST= | ||||
|       - MAIL_PORT= | ||||
|       - MAIL_SECURE= | ||||
|       - MAIL_USER= | ||||
|       - MAIL_PASSWORD= | ||||
|       - MAIL_FROM= | ||||
|       #- JELLYFIN_URL= | ||||
|       #- JELLYFIN_API_KEY= | ||||
|       #- SONARR_URL= | ||||
|       #- SONARR_API_KEY= | ||||
|       #- RADARR_URL= | ||||
|       #- RADARR_API_KEY= | ||||
|       #- MAIL_HOST= | ||||
|       #- MAIL_PORT= | ||||
|       #- MAIL_SECURE= | ||||
|       #- MAIL_USER= | ||||
|       #- MAIL_PASSWORD= | ||||
|       #- MAIL_FROM= | ||||
|       # Admin bootstrap (optional) | ||||
|       - ADMIN_USERNAME= | ||||
|       - ADMIN_PASSWORD= | ||||
|       - ADMIN_EMAIL= | ||||
|       #- ADMIN_USERNAME= | ||||
|       #- ADMIN_PASSWORD= | ||||
|       #- ADMIN_EMAIL= | ||||
|       # Cron schedule (default every 30min) | ||||
|       - CRON_SCHEDULE=*/30 * * * * | ||||
|     volumes: | ||||
|   | ||||
| @@ -7,20 +7,20 @@ class FirstRunSetupForm(forms.Form): | ||||
|     jellyfin_server_url = forms.URLField( | ||||
|         label="Jellyfin Server URL", | ||||
|         required=True, | ||||
|         help_text="Die URL deines Jellyfin-Servers" | ||||
|     help_text="URL of your Jellyfin server" | ||||
|     ) | ||||
|     jellyfin_api_key = forms.CharField( | ||||
|         label="Jellyfin API Key", | ||||
|         required=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_url = forms.URLField( | ||||
|         label="Sonarr URL", | ||||
|         required=False, | ||||
|         help_text="Die URL deines Sonarr-Servers" | ||||
|     help_text="URL of your Sonarr server" | ||||
|     ) | ||||
|     sonarr_api_key = forms.CharField( | ||||
|         label="Sonarr API Key", | ||||
| @@ -32,7 +32,7 @@ class FirstRunSetupForm(forms.Form): | ||||
|     radarr_url = forms.URLField( | ||||
|         label="Radarr URL", | ||||
|         required=False, | ||||
|         help_text="Die URL deines Radarr-Servers" | ||||
|     help_text="URL of your Radarr server" | ||||
|     ) | ||||
|     radarr_api_key = forms.CharField( | ||||
|         label="Radarr API Key", | ||||
| @@ -45,13 +45,13 @@ class JellyfinSettingsForm(forms.Form): | ||||
|         label="Jellyfin Server URL", | ||||
|         required=False, | ||||
|         widget=forms.URLInput(attrs=WIDE), | ||||
|         help_text="z.B. http://localhost:8096" | ||||
|     help_text="e.g. http://localhost:8096" | ||||
|     ) | ||||
|     jellyfin_api_key = forms.CharField( | ||||
|         label="Jellyfin API Key", | ||||
|         required=False, | ||||
|         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): | ||||
| @@ -68,18 +68,18 @@ class MailSettingsForm(forms.Form): | ||||
|     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_secure = forms.ChoiceField( | ||||
|         label="Sicherheit", required=False, | ||||
|         choices=[("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")] | ||||
|         label="Security", required=False, | ||||
|         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( | ||||
|         label="Mail Passwort", required=False, | ||||
|         label="Mail Password", required=False, | ||||
|         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): | ||||
|     username = forms.CharField(label="Benutzername", required=False) | ||||
|     email = forms.EmailField(label="E-Mail", required=False) | ||||
|     new_password = forms.CharField(label="Neues Passwort", required=False, widget=forms.PasswordInput) | ||||
|     repeat_password = forms.CharField(label="Passwort wiederholen", required=False, widget=forms.PasswordInput) | ||||
|     username = forms.CharField(label="Username", required=False) | ||||
|     email = forms.EmailField(label="Email", required=False) | ||||
|     new_password = forms.CharField(label="New password", 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 | ||||
|  | ||||
| class AppSettings(models.Model): | ||||
|     # Singleton-Pattern über feste ID | ||||
|     # Singleton pattern via fixed ID | ||||
|     singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False) | ||||
|  | ||||
|     # Jellyfin | ||||
| @@ -20,7 +20,7 @@ class AppSettings(models.Model): | ||||
|     mail_secure = models.CharField( | ||||
|         max_length=10, blank=True, null=True, | ||||
|         choices=( | ||||
|             ("", "Kein TLS/SSL"), | ||||
|             ("", "No TLS/SSL"), | ||||
|             ("starttls", "STARTTLS (Port 587)"), | ||||
|             ("ssl", "SSL/TLS (Port 465)"), | ||||
|             ("tls", "TLS (alias STARTTLS)"), | ||||
| @@ -30,7 +30,7 @@ class AppSettings(models.Model): | ||||
|     mail_password = models.CharField(max_length=255, 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_email = models.EmailField(blank=True, null=True) | ||||
|  | ||||
|   | ||||
| @@ -7,28 +7,28 @@ | ||||
|  | ||||
| {% block content %} | ||||
| <div class="setup-container"> | ||||
|     <h1>Willkommen bei Subscribarr</h1> | ||||
|     <p class="setup-intro">Lass uns deine Installation einrichten. Du brauchst mindestens einen Jellyfin-Server.</p> | ||||
|     <h1>Welcome to Subscribarr</h1> | ||||
|     <p class="setup-intro">Let's set up your installation. You need at least one Jellyfin server.</p> | ||||
|  | ||||
|     <form method="post" class="setup-form"> | ||||
|         {% csrf_token %} | ||||
|  | ||||
|         <div class="setup-section"> | ||||
|             <h2>Jellyfin Server (Erforderlich)</h2> | ||||
|             <h2>Jellyfin server (required)</h2> | ||||
|             <div class="form-group"> | ||||
|                 <label>Server URL</label> | ||||
|                 {{ 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 class="form-group"> | ||||
|                 <label>API Key</label> | ||||
|                 {{ 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 class="setup-section"> | ||||
|             <h2>Sonarr (Optional)</h2> | ||||
|             <h2>Sonarr (optional)</h2> | ||||
|             <div class="form-group"> | ||||
|                 <label>Server URL</label> | ||||
|                 {{ form.sonarr_url }} | ||||
| @@ -40,7 +40,7 @@ | ||||
|         </div> | ||||
|  | ||||
|         <div class="setup-section"> | ||||
|             <h2>Radarr (Optional)</h2> | ||||
|             <h2>Radarr (optional)</h2> | ||||
|             <div class="form-group"> | ||||
|                 <label>Server URL</label> | ||||
|                 {{ form.radarr_url }} | ||||
| @@ -51,7 +51,7 @@ | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <button type="submit" class="setup-submit">Installation abschließen</button> | ||||
|     <button type="submit" class="setup-submit">Finish setup</button> | ||||
|     </form> | ||||
| </div> | ||||
| {% endblock %} | ||||
| @@ -1,19 +1,22 @@ | ||||
| {% load static %} | ||||
| <!doctype html> | ||||
| <html lang="de"> | ||||
| <html lang="en"> | ||||
|  | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <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' %}"> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|     <div class="wrap"> | ||||
|         <div class="topbar"> | ||||
|             <div><a href="/" class="btn">← Zurück</a></div> | ||||
|             <div><strong>Einstellungen</strong></div> | ||||
|             <div style="display:flex; gap:8px; align-items:center;"> | ||||
|                 <a href="/" class="btn">← Back</a> | ||||
|                 <a href="{% url 'settingspanel:subscriptions' %}" class="btn">👥 Subscriptions</a> | ||||
|             </div> | ||||
|             <div><strong>Settings</strong></div> | ||||
|             <div></div> | ||||
|         </div> | ||||
|  | ||||
| @@ -29,12 +32,12 @@ | ||||
|                     <div class="row"> | ||||
|                         <label>Jellyfin Server URL</label> | ||||
|                         {{ 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 class="row"> | ||||
|                         <label>Jellyfin API Key</label> | ||||
|                         {{ 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> | ||||
|  | ||||
| @@ -46,8 +49,7 @@ | ||||
|                         <div class="inline"> | ||||
|                             <div class="field">{{ arr_form.sonarr_url }}</div> | ||||
|                             <div class="inline-actions"> | ||||
|                                 <button class="btn" type="button" onclick="testConnection('sonarr', this)">Test | ||||
|                                     Sonarr</button> | ||||
|                                 <button class="btn" type="button" onclick="testConnection('sonarr', this)">Test Sonarr</button> | ||||
|                                 <span id="sonarrStatus" class="badge muted">—</span> | ||||
|                             </div> | ||||
|                         </div> | ||||
| @@ -64,8 +66,7 @@ | ||||
|                         <div class="inline"> | ||||
|                             <div class="field">{{ arr_form.radarr_url }}</div> | ||||
|                             <div class="inline-actions"> | ||||
|                                 <button class="btn" type="button" onclick="testConnection('radarr', this)">Test | ||||
|                                     Radarr</button> | ||||
|                                 <button class="btn" type="button" onclick="testConnection('radarr', this)">Test Radarr</button> | ||||
|                                 <span id="radarrStatus" class="badge muted">—</span> | ||||
|                             </div> | ||||
|                         </div> | ||||
| @@ -76,32 +77,31 @@ | ||||
|                         {{ arr_form.radarr_api_key }} | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="help">Klicke „Test …“, um die Verbindung gegen <code>/api/v3/system/status</code> zu | ||||
|                         prüfen.</div> | ||||
|                     <div class="help">Click “Test …” to verify against <code>/api/v3/system/status</code>.</div> | ||||
|                 </div> | ||||
|  | ||||
|                 <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>Port</label>{{ mail_form.mail_port }}</div> | ||||
|                     <div class="row"><label>Sicherheit</label>{{ mail_form.mail_secure }}</div> | ||||
|                     <div class="row"><label>Benutzer</label>{{ mail_form.mail_user }}</div> | ||||
|                     <div class="row"><label>Passwort</label>{{ mail_form.mail_password }}</div> | ||||
|                     <div class="row"><label>Absender</label>{{ mail_form.mail_from }}</div> | ||||
|                     <div class="row"><label>Security</label>{{ mail_form.mail_secure }}</div> | ||||
|                     <div class="row"><label>User</label>{{ mail_form.mail_user }}</div> | ||||
|                     <div class="row"><label>Password</label>{{ mail_form.mail_password }}</div> | ||||
|                     <div class="row"><label>From</label>{{ mail_form.mail_from }}</div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="card"> | ||||
|                     <h2>Konto</h2> | ||||
|                     <div class="row"><label>Benutzername</label>{{ account_form.username }}</div> | ||||
|                     <div class="row"><label>E-Mail</label>{{ account_form.email }}</div> | ||||
|                     <div class="row"><label>Neues Passwort</label>{{ account_form.new_password }}</div> | ||||
|                     <div class="row"><label>Passwort wiederholen</label>{{ account_form.repeat_password }}</div> | ||||
|                     <div class="help">Nur Oberfläche – Umsetzung Passwortänderung später.</div> | ||||
|                     <h2>Account</h2> | ||||
|                     <div class="row"><label>Username</label>{{ account_form.username }}</div> | ||||
|                     <div class="row"><label>Email</label>{{ account_form.email }}</div> | ||||
|                     <div class="row"><label>New password</label>{{ account_form.new_password }}</div> | ||||
|                     <div class="row"><label>Repeat password</label>{{ account_form.repeat_password }}</div> | ||||
|                     <div class="help">Only UI – implementing password change later.</div> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div style="margin-top:16px"> | ||||
|                 <button class="btn btn-primary" type="submit">Speichern</button> | ||||
|                 <button class="btn btn-primary" type="submit">Save</button> | ||||
|             </div> | ||||
|         </form> | ||||
|     </div> | ||||
| @@ -116,25 +116,25 @@ | ||||
|                 .then(r => r.json()) | ||||
|                 .then(data => { | ||||
|                     if (data.ok) { | ||||
|                         alert(kind + " Verbindung erfolgreich!"); | ||||
|             alert(kind + " connection successful!"); | ||||
|                     } 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) { | ||||
|             const el = document.getElementById(kind + "Status"); | ||||
|             if (!el) return; | ||||
|             el.classList.remove("ok", "err", "muted"); | ||||
|             el.title = tooltip || "";               // voller Fehlertext im Tooltip | ||||
|             el.title = tooltip || "";               // full error text in tooltip | ||||
|             if (state === "ok") { | ||||
|                 el.classList.add("ok"); | ||||
|                 el.textContent = "Verbunden"; | ||||
|                 el.textContent = "Connected"; | ||||
|             } else if (state === "err") { | ||||
|                 el.classList.add("err"); | ||||
|                 el.textContent = "Fehler"; | ||||
|                 el.textContent = "Error"; | ||||
|             } else { | ||||
|                 el.classList.add("muted"); | ||||
|                 el.textContent = "—"; | ||||
| @@ -147,19 +147,19 @@ | ||||
|             const url = urlEl ? urlEl.value.trim() : ""; | ||||
|             const key = keyEl ? keyEl.value.trim() : ""; | ||||
|  | ||||
|             setBadge(kind, "muted", "Teste…"); | ||||
|             setBadge(kind, "muted", "Testing…"); | ||||
|             if (btnEl) { btnEl.disabled = true; } | ||||
|  | ||||
|             fetch(`/settings/test-connection/?kind=${encodeURIComponent(kind)}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`) | ||||
|                 .then(r => r.json()) | ||||
|                 .then(data => { | ||||
|                     if (data.ok) { | ||||
|                         setBadge(kind, "ok", "Verbunden", ""); | ||||
|             setBadge(kind, "ok", "Connected", ""); | ||||
|                     } 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; }); | ||||
|         } | ||||
|  | ||||
|   | ||||
							
								
								
									
										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 .views import SettingsView, test_connection, first_run | ||||
| from .views import SettingsView, test_connection, first_run, subscriptions_overview | ||||
|  | ||||
| app_name = "settingspanel" | ||||
| urlpatterns = [ | ||||
|     path("", SettingsView.as_view(), name="index"), | ||||
|     path("test-connection/", test_connection, name="test_connection"), | ||||
|     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 accounts.utils import jellyfin_admin_required | ||||
| from django.contrib.auth import get_user_model | ||||
| from arr_api.models import SeriesSubscription, MovieSubscription | ||||
| from django.db.models import Count | ||||
| import requests | ||||
|  | ||||
| def needs_setup(): | ||||
| @@ -32,7 +34,7 @@ def first_run(request): | ||||
|             settings.radarr_api_key = form.cleaned_data['radarr_api_key'] | ||||
|             settings.save() | ||||
|              | ||||
|             messages.success(request, 'Setup erfolgreich abgeschlossen!') | ||||
|             messages.success(request, 'Setup completed successfully!') | ||||
|             return redirect('accounts:login') | ||||
|     else: | ||||
|         form = FirstRunSetupForm() | ||||
| @@ -53,9 +55,9 @@ def test_connection(request): | ||||
|     url = (request.GET.get("url") or "").strip() | ||||
|     key = (request.GET.get("key") or "").strip() | ||||
|     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: | ||||
|         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: | ||||
|         r = requests.get( | ||||
| @@ -111,29 +113,81 @@ class SettingsView(View): | ||||
|                 "jellyfin_form": jellyfin_form, | ||||
|                 "arr_form": arr_form, | ||||
|                 "mail_form": mail_form, | ||||
|                 "account_form": acc_form | ||||
|                 "account_form": acc_form, | ||||
|             }) | ||||
|  | ||||
|         cfg = AppSettings.current() | ||||
|  | ||||
|         # Update Jellyfin settings | ||||
|         cfg.jellyfin_server_url = jellyfin_form.cleaned_data["jellyfin_server_url"] or None | ||||
|         cfg.jellyfin_api_key = jellyfin_form.cleaned_data["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.jellyfin_server_url = jellyfin_form.cleaned_data.get("jellyfin_server_url") or None | ||||
|         cfg.jellyfin_api_key    = jellyfin_form.cleaned_data.get("jellyfin_api_key") or None | ||||
|  | ||||
|         cfg.mail_host     = mail_form.cleaned_data["mail_host"] or None | ||||
|         cfg.mail_port     = mail_form.cleaned_data["mail_port"] or None | ||||
|         cfg.mail_secure   = mail_form.cleaned_data["mail_secure"] or "" | ||||
|         cfg.mail_user     = mail_form.cleaned_data["mail_user"] or None | ||||
|         cfg.mail_password = mail_form.cleaned_data["mail_password"] or None | ||||
|         cfg.mail_from     = mail_form.cleaned_data["mail_from"] or None | ||||
|         # Update Sonarr/Radarr settings | ||||
|         cfg.sonarr_url     = arr_form.cleaned_data.get("sonarr_url") or None | ||||
|         cfg.sonarr_api_key = arr_form.cleaned_data.get("sonarr_api_key") or None | ||||
|         cfg.radarr_url     = arr_form.cleaned_data.get("radarr_url") or None | ||||
|         cfg.radarr_api_key = arr_form.cleaned_data.get("radarr_api_key") or None | ||||
|  | ||||
|         cfg.acc_username = acc_form.cleaned_data["username"] or None | ||||
|         cfg.acc_email    = acc_form.cleaned_data["email"] or None | ||||
|         # Update Mail settings | ||||
|         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() | ||||
|         messages.success(request, "Einstellungen gespeichert (DB).") | ||||
|         messages.success(request, "Settings saved (DB).") | ||||
|         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; | ||||
| } | ||||
|  | ||||
| .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 button { | ||||
|     display: flex; | ||||
| @@ -151,9 +172,9 @@ body { | ||||
|     background: var(--accent); | ||||
| } | ||||
|  | ||||
| .nav-logout { | ||||
|     color: #ef4444 !important; | ||||
| } | ||||
| .nav-logout { 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 { | ||||
|     color: var(--accent) !important; | ||||
|   | ||||
| @@ -39,6 +39,14 @@ h1 { | ||||
|     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 { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
| @@ -67,6 +75,24 @@ h1 { | ||||
|     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 { | ||||
|     display: inline-flex; | ||||
|     background: #0f0f17; | ||||
| @@ -99,6 +125,10 @@ h1 { | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media (max-width: 640px) { | ||||
|     .grid { grid-template-columns: 1fr; gap: 12px; } | ||||
| } | ||||
|  | ||||
| .card { | ||||
|     background: var(--panel); | ||||
|     border: 1px solid var(--panel-b); | ||||
| @@ -111,6 +141,12 @@ h1 { | ||||
|     cursor: pointer | ||||
| } | ||||
|  | ||||
| @media (max-width: 640px) { | ||||
|     .card { gap: 10px; } | ||||
|     .poster { width: 90px; height: 135px; } | ||||
|     .title { font-size: .98rem; } | ||||
| } | ||||
|  | ||||
| .card:active, | ||||
| .card:hover { | ||||
|     transform: translateY(-2px); | ||||
| @@ -260,6 +296,12 @@ h1 { | ||||
|     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 { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| @@ -344,6 +386,10 @@ h1 { | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media (max-width: 640px) { | ||||
|     .modal-body { grid-template-columns: 1fr; } | ||||
| } | ||||
|  | ||||
| .section-block { | ||||
|     background: #101327; | ||||
|     border: 1px solid #20223a; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| {% load static %} | ||||
| <!DOCTYPE html> | ||||
| <html lang="de"> | ||||
| <html lang="en"> | ||||
|  | ||||
| <head> | ||||
|     <meta charset="UTF-8"> | ||||
| @@ -14,15 +14,18 @@ | ||||
|     <nav class="main-nav"> | ||||
|         <div class="nav-content"> | ||||
|             <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 %} | ||||
|                 <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"> | ||||
|                     <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> | ||||
|                         <circle cx="12" cy="7" r="4"></circle> | ||||
|                     </svg> | ||||
|                     Profil | ||||
|                     Profile | ||||
|                 </a> | ||||
|                 {% if user.is_jellyfin_admin %} | ||||
|                 <a href="{% url 'settingspanel:index' %}" class="nav-admin"> | ||||
| @@ -34,14 +37,14 @@ | ||||
|                 {% endif %} | ||||
|                 <form method="post" action="{% url 'accounts:logout' %}" class="inline-form"> | ||||
|                     {% 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" | ||||
|                             stroke-width="2"> | ||||
|                             <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> | ||||
|                             <line x1="21" y1="12" x2="9" y2="12"></line> | ||||
|                         </svg> | ||||
|                         Abmelden | ||||
|                         Sign out | ||||
|                     </button> | ||||
|                 </form> | ||||
|                 {% else %} | ||||
| @@ -51,7 +54,7 @@ | ||||
|                         <polyline points="10 17 15 12 10 7"></polyline> | ||||
|                         <line x1="15" y1="12" x2="3" y2="12"></line> | ||||
|                     </svg> | ||||
|                     Anmelden | ||||
|                     Sign in | ||||
|                 </a> | ||||
|                 <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"> | ||||
| @@ -60,13 +63,25 @@ | ||||
|                         <line x1="20" y1="8" x2="20" y2="14"></line> | ||||
|                         <line x1="23" y1="11" x2="17" y2="11"></line> | ||||
|                     </svg> | ||||
|                     Registrieren | ||||
|                     Register | ||||
|                 </a> | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|         </div> | ||||
|     </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 %} | ||||
| </body> | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 jschaufuss@leitwerk.de
					jschaufuss@leitwerk.de