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