usermanagement/translation/calendar

This commit is contained in:
jschaufuss@leitwerk.de
2025-08-11 12:27:30 +02:00
parent 13a5286357
commit c2bb64d961
27 changed files with 998 additions and 240 deletions

View File

@@ -10,15 +10,15 @@ class CustomUserCreationForm(UserCreationForm):
fields = ('username', 'email', 'password1', 'password2')
class CustomUserChangeForm(UserChangeForm):
password = None # Passwort-Änderung über extra Formular
password = None # Password change via separate form
class Meta:
model = User
fields = ('email',)
widgets = {
'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'E-Mail-Adresse'}),
'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'Email address'}),
}
class JellyfinLoginForm(forms.Form):
username = forms.CharField(label='Benutzername', widget=forms.TextInput(attrs={'class': 'form-control'}))
password = forms.CharField(label='Passwort', widget=forms.PasswordInput(attrs={'class': 'form-control'}))
username = forms.CharField(label='Username', widget=forms.TextInput(attrs={'class': 'form-control'}))
password = forms.CharField(label='Password', widget=forms.PasswordInput(attrs={'class': 'form-control'}))

View File

@@ -4,9 +4,9 @@ from django.utils.translation import gettext_lazy as _
class User(AbstractUser):
"""
Custom User Model mit zusätzlichen Feldern und Berechtigungen.
Normale User können nur ihre eigenen Daten bearbeiten.
Admin-User können alles.
Custom User Model with additional fields and permissions.
Regular users can only edit their own data.
Admin users can edit everything.
"""
email = models.EmailField(_("email address"), unique=True)
bio = models.TextField(max_length=500, blank=True)
@@ -26,7 +26,7 @@ class User(AbstractUser):
client = JellyfinClient()
return client.is_admin(self.jellyfin_user_id, self.jellyfin_token)
except:
# Im Fehlerfall den lokalen Status verwenden
# On error, fall back to local status
return self.is_admin
@property

View File

@@ -2,14 +2,14 @@
{% block content %}
<div class="auth-container">
<h2>Anmelden</h2>
<h2>Sign in</h2>
<form method="post" class="auth-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn-primary">Anmelden</button>
<button type="submit" class="btn-primary">Sign in</button>
</form>
<div class="auth-links">
<p>Noch kein Konto? <a href="{% url 'accounts:register' %}">Jetzt registrieren</a></p>
<p>Don't have an account? <a href="{% url 'accounts:register' %}">Register now</a></p>
</div>
</div>
{% endblock %}

View File

@@ -2,11 +2,11 @@
{% block content %}
<div class="auth-container">
<h2>Passwort ändern</h2>
<h2>Change password</h2>
<form method="post" class="auth-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn-primary">Passwort ändern</button>
<button type="submit" class="btn-primary">Change password</button>
</form>
</div>
{% endblock %}

View File

@@ -2,8 +2,8 @@
{% block content %}
<div class="auth-container">
<h2>Passwort geändert</h2>
<p>Ihr Passwort wurde erfolgreich geändert.</p>
<p><a href="{% url 'accounts:profile' %}">Zurück zum Profil</a></p>
<h2>Password changed</h2>
<p>Your password has been changed successfully.</p>
<p><a href="{% url 'accounts:profile' %}">Back to profile</a></p>
</div>
{% endblock %}

View File

@@ -7,7 +7,7 @@
{% block content %}
<div class="profile-container">
<h2>Hallo, {{ user.username }}</h2>
<h2>Hello, {{ user.username }}</h2>
{% if messages %}
<div class="messages">
@@ -18,24 +18,24 @@
{% endif %}
<div class="profile-section">
<h3>E-Mail-Adresse</h3>
<h3>Email address</h3>
<form method="post" class="profile-form compact-form">
{% csrf_token %}
<div class="form-row">
<label for="id_email">E-Mail</label>
<label for="id_email">Email</label>
{{ form.email }}
</div>
<button type="submit" class="btn-primary">Speichern</button>
<button type="submit" class="btn-primary">Save</button>
</form>
{% if user.jellyfin_server %}
<div class="jellyfin-info">
<h4>Jellyfin-Verbindung</h4>
<h4>Jellyfin connection</h4>
<p>
Server: {{ user.jellyfin_server }}<br>
Status: {% if user.jellyfin_token %}Verbunden{% else %}Nicht verbunden{% endif %}<br>
Status: {% if user.jellyfin_token %}Connected{% else %}Not connected{% endif %}<br>
{% if user.is_jellyfin_admin %}
<span class="badge badge-admin">Jellyfin Administrator</span>
<span class="badge badge-admin">Jellyfin administrator</span>
{% endif %}
</p>
</div>
@@ -43,9 +43,9 @@
</div>
<div class="profile-section">
<h3>Meine Abonnements</h3>
<h3>My subscriptions</h3>
<h4>Serien</h4>
<h4>Series</h4>
{% if series_subs %}
<div class="subscription-list">
{% for sub in series_subs %}
@@ -53,11 +53,11 @@
{% if sub.series_poster %}
<img src="{{ sub.series_poster }}" alt="{{ sub.series_title }}" class="subscription-poster">
{% else %}
<img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster">
<img src="https://via.placeholder.com/80x120?text=No+Poster" alt="" class="subscription-poster">
{% endif %}
<div class="subscription-info">
<div class="subscription-title">{{ sub.series_title }}</div>
<div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div>
<div class="subscription-date">Subscribed on {{ sub.created_at|date:"d.m.Y" }}</div>
{% if sub.series_overview %}
<div class="subscription-overview">{{ sub.series_overview|truncatechars:100 }}</div>
{% endif %}
@@ -66,10 +66,10 @@
{% endfor %}
</div>
{% else %}
<p class="muted">Keine Serien abonniert.</p>
<p class="muted">No series subscribed.</p>
{% endif %}
<h4>Filme</h4>
<h4>Movies</h4>
{% if movie_subs %}
<div class="subscription-list">
{% for sub in movie_subs %}
@@ -77,11 +77,11 @@
{% if sub.poster %}
<img src="{{ sub.poster }}" alt="{{ sub.title }}" class="subscription-poster">
{% else %}
<img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster">
<img src="https://via.placeholder.com/80x120?text=No+Poster" alt="" class="subscription-poster">
{% endif %}
<div class="subscription-info">
<div class="subscription-title">{{ sub.title }}</div>
<div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div>
<div class="subscription-date">Subscribed on {{ sub.created_at|date:"d.m.Y" }}</div>
{% if sub.overview %}
<div class="subscription-overview">{{ sub.overview|truncatechars:100 }}</div>
{% endif %}
@@ -90,7 +90,7 @@
{% endfor %}
</div>
{% else %}
<p class="muted">Keine Filme abonniert.</p>
<p class="muted">No movies subscribed.</p>
{% endif %}
</div>
</div>

View File

@@ -2,14 +2,14 @@
{% block content %}
<div class="auth-container">
<h2>Registrieren</h2>
<h2>Register</h2>
<form method="post" class="auth-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn-primary">Registrieren</button>
<button type="submit" class="btn-primary">Register</button>
</form>
<div class="auth-links">
<p>Bereits ein Konto? <a href="{% url 'accounts:login' %}">Jetzt anmelden</a></p>
<p>Already have an account? <a href="{% url 'accounts:login' %}">Sign in</a></p>
</div>
</div>
{% endblock %}

View File

@@ -7,7 +7,7 @@ from django.contrib import messages
class JellyfinClient:
def __init__(self):
# Basis-Einstellungen aus den Django-Settings
# Base settings from Django settings
self.client = settings.JELLYFIN_CLIENT
self.version = settings.JELLYFIN_VERSION
self.device = settings.JELLYFIN_DEVICE
@@ -18,13 +18,13 @@ class JellyfinClient:
def authenticate(self, username, password):
"""Authenticate with Jellyfin and return user info if successful"""
if not self.server_url:
raise ValueError("Keine Server-URL angegeben")
raise ValueError("No server URL provided")
# Stelle sicher, dass die URL ein Protokoll hat
# Ensure the URL has a protocol
if not self.server_url.startswith(('http://', 'https://')):
self.server_url = f'http://{self.server_url}'
# Entferne trailing slashes
# Remove trailing slashes
self.server_url = self.server_url.rstrip('/')
headers = {
@@ -57,13 +57,13 @@ class JellyfinClient:
'is_admin': data['User'].get('Policy', {}).get('IsAdministrator', False)
}
except requests.exceptions.ConnectionError:
raise ValueError("Verbindung zum Server nicht möglich. Bitte überprüfen Sie die Server-URL.")
raise ValueError("Unable to connect to the server. Please check the server URL.")
except requests.exceptions.Timeout:
raise ValueError("Zeitüberschreitung bei der Verbindung zum Server.")
raise ValueError("Connection to the server timed out.")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
return None # Authentifizierung fehlgeschlagen
raise ValueError(f"HTTP-Fehler: {e.response.status_code}")
raise ValueError(f"HTTP error: {e.response.status_code}")
except Exception as e:
return None
@@ -106,11 +106,11 @@ def jellyfin_admin_required(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
if not request.user.is_authenticated:
messages.error(request, 'Sie müssen angemeldet sein, um diese Seite zu sehen.')
messages.error(request, 'You must be logged in to view this page.')
return redirect('accounts:login')
if not request.user.is_jellyfin_admin:
messages.error(request, 'Sie benötigen Admin-Rechte, um diese Seite zu sehen.')
messages.error(request, 'You need admin rights to view this page.')
return redirect('index')
return view_func(request, *args, **kwargs)

View File

@@ -17,7 +17,7 @@ class RegisterView(CreateView):
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, 'Registrierung erfolgreich! Sie können sich jetzt anmelden.')
messages.success(self.request, 'Registration successful! You can now sign in.')
return response
@login_required
@@ -26,12 +26,12 @@ def profile(request):
form = CustomUserChangeForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, 'E-Mail gespeichert.')
messages.success(request, 'Email saved.')
return redirect('accounts:profile')
else:
form = CustomUserChangeForm(instance=request.user)
# Lade Abonnements
# Load subscriptions
series_subs = request.user.series_subscriptions.all()
movie_subs = request.user.movie_subscriptions.all()
@@ -40,7 +40,7 @@ def profile(request):
from settingspanel.models import AppSettings
from arr_api.services import sonarr_get_series, radarr_lookup_movie_by_title
cfg = AppSettings.current()
# Serien
# Series
for sub in series_subs:
if not sub.series_poster and sub.series_id:
details = sonarr_get_series(sub.series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key)
@@ -51,7 +51,7 @@ def profile(request):
if not sub.series_genres:
sub.series_genres = details.get('series_genres') or []
sub.save(update_fields=['series_poster', 'series_overview', 'series_genres'])
# Filme
# Movies
for sub in movie_subs:
if not sub.poster:
details = radarr_lookup_movie_by_title(sub.title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key)
@@ -84,7 +84,7 @@ def jellyfin_login(request):
app_settings = AppSettings.current()
server_url = app_settings.get_jellyfin_url()
if not server_url:
messages.error(request, 'Jellyfin Server ist nicht konfiguriert. Bitte Setup abschließen.')
messages.error(request, 'Jellyfin server is not configured. Please complete setup.')
return render(request, 'accounts/login.html', {'form': form})
try:
@@ -93,7 +93,7 @@ def jellyfin_login(request):
auth_result = client.authenticate(username, password)
if not auth_result:
messages.error(request, 'Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.')
messages.error(request, 'Sign in failed. Please check your credentials.')
return render(request, 'accounts/login.html', {'form': form})
# Existierenden User finden oder neu erstellen
@@ -116,13 +116,13 @@ def jellyfin_login(request):
user.save()
login(request, user)
messages.success(request, f'Willkommen, {username}!')
messages.success(request, f'Welcome, {username}!')
return redirect('arr_api:index')
except ValueError as e:
messages.error(request, str(e))
except Exception as e:
messages.error(request, f'Verbindungsfehler: {str(e)}')
messages.error(request, f'Connection error: {str(e)}')
# invalid form or error path
return render(request, 'accounts/login.html', {'form': form})

View File

@@ -3,12 +3,12 @@ from django.utils import timezone
from arr_api.notifications import check_and_notify_users
class Command(BaseCommand):
help = 'Prüft neue Medien und sendet Benachrichtigungen'
help = 'Checks for new media and sends notifications'
def handle(self, *args, **kwargs):
self.stdout.write(f'[{timezone.now()}] Starte Medien-Check...')
self.stdout.write(f'[{timezone.now()}] Starting media check...')
try:
check_and_notify_users()
self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Medien-Check erfolgreich beendet'))
self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Media check finished successfully'))
except Exception as e:
self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Fehler beim Medien-Check: {str(e)}'))
self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Error during media check: {str(e)}'))

View File

@@ -12,7 +12,7 @@ class SeriesSubscription(models.Model):
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user', 'series_id'] # Ein User kann eine Serie nur einmal abonnieren
unique_together = ['user', 'series_id'] # A user can subscribe to a series only once
def __str__(self):
return self.series_title
@@ -29,18 +29,16 @@ class MovieSubscription(models.Model):
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user', 'movie_id'] # Ein User kann einen Film nur einmal abonnieren
unique_together = ['user', 'movie_id'] # A user can subscribe to a movie only once
def __str__(self):
return self.title
class SentNotification(models.Model):
"""
Speichert gesendete Benachrichtigungen um Duplikate zu vermeiden
"""
"""Store sent notifications to avoid duplicates"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
media_id = models.IntegerField()
media_type = models.CharField(max_length=10) # 'series' oder 'movie'
media_type = models.CharField(max_length=10) # 'series' or 'movie'
media_title = models.CharField(max_length=255)
air_date = models.DateField()
sent_at = models.DateTimeField(auto_now_add=True)

View File

@@ -68,7 +68,7 @@ def send_notification_email(
release_type=None,
):
"""
Sendet eine Benachrichtigungs-E-Mail an einen User mit erweiterten Details
Sends a notification email to a user with extended details
"""
eff = _set_runtime_email_settings()
logger.info(
@@ -94,7 +94,7 @@ def send_notification_email(
context = {
'username': user.username,
'title': media_title,
'type': 'Serie' if media_type == 'series' else 'Film',
'type': 'Series' if media_type == 'series' else 'Movie',
'overview': overview,
'poster_url': poster_url,
'episode_title': episode_title,
@@ -105,7 +105,7 @@ def send_notification_email(
'release_type': release_type,
}
subject = f"Neue {context['type']} verfügbar: {media_title}"
subject = f"New {context['type']} available: {media_title}"
message = render_to_string('arr_api/email/new_media_notification.html', context)
send_mail(
@@ -209,8 +209,8 @@ def get_todays_radarr_calendar():
def check_jellyfin_availability(user, media_id, media_type):
"""
Ersetzt: Wir prüfen Verfügbarkeit über Sonarr/Radarr (hasFile),
was zuverlässig ist, wenn Jellyfin dieselben Ordner scannt.
Replaced: We check availability via Sonarr/Radarr (hasFile),
which is reliable if Jellyfin scans the same folders.
"""
# user is unused here; kept for backward compatibility
if media_type == 'series':

View 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">&times;</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 %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Subscribarr Übersicht{% endblock %}
{% block title %}Subscribarr Overview{% endblock %}
{% block extra_style %}
<link rel="stylesheet" href="{% static 'css/index.css' %}">
@@ -20,26 +20,30 @@
<div class="controls">
<form method="get" class="controls-form">
<input type="hidden" name="kind" value="{{ kind|default:'all' }}">
<input type="text" name="q" placeholder="Suche Serien/Filme…" value="{{ query|default:'' }}">
<input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Zeitraum in Tagen">
<button type="submit">Suchen</button>
<input type="text" name="q" placeholder="Search series/movies…" value="{{ query|default:'' }}">
<input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Time range (days)">
<button type="submit">Search</button>
</form>
<nav class="seg" aria-label="Typ filtern">
<nav class="seg" aria-label="Filter type">
{% with qs=query|urlencode %}
<a href="?kind=all&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
class="{% if kind == 'all' %}active{% endif %}">Alle</a>
class="{% if kind == 'all' %}active{% endif %}">All</a>
<a href="?kind=series&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
class="{% if kind == 'series' %}active{% endif %}">Serien</a>
class="{% if kind == 'series' %}active{% endif %}">Series</a>
<a href="?kind=movies&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
class="{% if kind == 'movies' %}active{% endif %}">Filme</a>
class="{% if kind == 'movies' %}active{% endif %}">Movies</a>
{% endwith %}
</nav>
<div class="controls-actions">
<a href="/calendar/" class="btn btn-accent" title="Open calendar">📅 Calendar</a>
</div>
</div>
{% if show_series %}
<div class="section">
<h2>Laufende Serien</h2>
<h2>Ongoing series</h2>
<div class="grid">
{% for s in series_grouped %}
<div class="card" data-series-id="{{ s.seriesId }}" data-title="{{ s.seriesTitle|escape }}"
@@ -60,7 +64,7 @@
<span class="muted" data-dt="{{ e.airDateUtc|default:'' }}"></span>
</div>
{% empty %}
<div class="muted">Keine kommenden Episoden.</div>
<div class="muted">No upcoming episodes.</div>
{% endfor %}
</div>
</div>
@@ -72,7 +76,7 @@
{% endwith %}
</div>
{% empty %}
<p class="muted">Keine Serien gefunden.</p>
<p class="muted">No series found.</p>
{% endfor %}
</div>
</div>
@@ -80,7 +84,7 @@
{% if show_movies %}
<div class="section">
<h2>Anstehende Filme</h2>
<h2>Upcoming movies</h2>
<div class="grid">
{% for m in movies %}
<div class="movie-card" data-kind="movie" data-title="{{ m.title|escape }}"
@@ -92,13 +96,13 @@
{% endif %}
<div class="title">{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}</div>
<div class="muted">
{% if m.inCinemas %}Kino: <span data-dt="{{ m.inCinemas }}"></span>{% endif %}
{% if m.inCinemas %}In theaters: <span data-dt="{{ m.inCinemas }}"></span>{% endif %}
{% if m.digitalRelease %}<br>Digital: <span data-dt="{{ m.digitalRelease }}"></span>{% endif %}
{% if m.physicalRelease %}<br>Disc: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %}
{% if m.physicalRelease %}<br>Physical: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %}
</div>
</div>
{% empty %}
<p class="muted">Keine Filme gefunden.</p>
<p class="muted">No movies found.</p>
{% endfor %}
</div>
</div>
@@ -118,17 +122,17 @@
<div id="mSub" class="m-sub"></div>
<div id="mBadges" class="badges"></div>
</div>
<button class="modal-close" title="Schließen" aria-label="Schließen">&times;</button>
<button class="modal-close" title="Close" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="section-block">
<div class="section-title">Beschreibung</div>
<div class="section-title">Overview</div>
<div id="mOverview" class="desc muted"></div>
</div>
<div class="section-block">
<div class="section-title">Kommende Episoden</div>
<div class="section-title">Upcoming episodes</div>
<div class="section-divider"></div>
<div id="mEpisodes"></div>
</div>
@@ -153,6 +157,7 @@
const mSub = $("#mSub");
const epSection = mEpisodes.closest(".section-block");
const subscribeBtn = $("#subscribeBtn");
const bc = ('BroadcastChannel' in window) ? new BroadcastChannel('subscribarr-sub') : null;
let lastClickedCard = null;
@@ -178,7 +183,7 @@
return "movie:" + (card.dataset.title || "");
}
// Cache für Abonnement-Status
// Cache for subscription state
const subCache = new Map();
async function loadAllSubs() {
@@ -221,45 +226,48 @@
});
if (resp.status === 403) {
// Nicht eingeloggt
// Not logged in
window.location.href = '/accounts/login/?next=' + encodeURIComponent(window.location.pathname);
return;
}
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
// Cache aktualisieren
// Update cache
if (on) {
subCache.set(k, true);
} else {
subCache.delete(k);
}
// Cross-tab/page notify
if (bc) bc.postMessage({ type: 'sub_change', kind: type, key: id, on });
} catch (err) {
console.error("Failed to update subscription:", err);
// Cache-Update rückgängig machen bei Fehler
// Revert optimistic cache on error
if (on) {
subCache.delete(k);
} else {
subCache.set(k, true);
}
// Fehlermeldung anzeigen
// Show error
const errorMsg = document.createElement('div');
errorMsg.className = 'error-message';
errorMsg.textContent = 'Fehler beim Aktualisieren des Abonnements. Bitte versuchen Sie es später erneut.';
errorMsg.textContent = 'Failed to update subscription. Please try again later.';
document.body.appendChild(errorMsg);
setTimeout(() => errorMsg.remove(), 3000);
}
}
function applySubUI(card, on) {
if (card) card.classList.toggle("subscribed", !!on); // grüne Outline via .subscribed (CSS)
if (card) card.classList.toggle("subscribed", !!on); // green outline via .subscribed (CSS)
if (subscribeBtn) {
subscribeBtn.textContent = on ? "Unsubscribe" : "Subscribe";
subscribeBtn.setAttribute("aria-pressed", on ? "true" : "false");
}
}
// Beim Laden: Alle Abonnements in einem API-Call laden
// On load: fetch all subscriptions in a single API call
(async () => {
await loadAllSubs();
const cards = $$(".card, .movie-card");
@@ -268,7 +276,30 @@
});
})();
// ===== Serien-Karten öffnen =====
// Listen to subscription changes from other pages (e.g., calendar)
if (bc) {
bc.onmessage = (evt) => {
const msg = evt.data || {};
if (msg.type !== 'sub_change') return;
const { kind, key, on } = msg;
// Keep cache in sync
const cacheKey = `${kind}:${key}`;
if (on) subCache.set(cacheKey, true); else subCache.delete(cacheKey);
// Update matching cards
const cards = $$(".card, .movie-card");
cards.forEach(card => {
const isSeries = card.classList.contains('card') && card.dataset.seriesId;
const isMovie = card.classList.contains('movie-card') && card.dataset.title;
let match = false;
if (kind === 'series' && isSeries && String(card.dataset.seriesId) === String(key)) match = true;
if (kind === 'movie' && isMovie && (card.dataset.title || '') === key) match = true;
if (match) applySubUI(card, on);
});
};
}
// ===== Open series cards =====
$$(".card").forEach(card => {
card.addEventListener("click", () => {
lastClickedCard = card;
@@ -278,22 +309,22 @@
const poster = card.dataset.poster || "";
const overview = card.dataset.overview || "";
// Episoden aus eingebettetem JSON <script id="eps-<id>">
// Episodes from embedded JSON <script id="eps-<id>">
let episodes = [];
const script = document.getElementById("eps-" + id);
if (script) { try { episodes = JSON.parse(script.textContent); } catch { } }
// Modal befüllen
// Fill modal
mTitle.textContent = title;
mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster";
mPoster.alt = title;
mOverview.textContent = overview || "Keine Beschreibung verfügbar.";
mOverview.textContent = overview || "No overview available.";
mSub.textContent = episodes.length
? `${episodes.length} kommende Episode(n)`
: "Keine kommenden Episoden";
? `${episodes.length} upcoming episode(s)`
: "No upcoming episodes";
// Genres-Badges, falls data-genres vorhanden
// Genre badges if data-genres is present
mBadges.innerHTML = "";
if (card.dataset.genres) {
card.dataset.genres.split(",").map(s => s.trim()).filter(Boolean).forEach(g => {
@@ -304,7 +335,7 @@
});
}
// Episodenbereich
// Episodes section
epSection.style.display = "";
mEpisodes.innerHTML = "";
if (!episodes.length) {
@@ -323,10 +354,10 @@
});
}
// Subscribe-UI für diese Karte setzen
// Set subscribe UI for this card
applySubUI(lastClickedCard, loadSub(lastClickedCard));
// Status nochmal aktualisieren zur Sicherheit
// Refresh status again for safety
loadAllSubs().then(() => {
applySubUI(lastClickedCard, loadSub(lastClickedCard));
});
@@ -335,7 +366,7 @@
});
});
// ===== Film-Karten öffnen =====
// ===== Open movie cards =====
$$(".movie-card").forEach(card => {
card.addEventListener("click", () => {
lastClickedCard = card;
@@ -347,19 +378,19 @@
mTitle.textContent = title;
mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster";
mPoster.alt = title;
mOverview.textContent = overview || "Keine Beschreibung verfügbar.";
mOverview.textContent = overview || "No overview available.";
mSub.textContent = "";
mBadges.innerHTML = "";
// Episodenbereich ausblenden
// Hide episodes section
epSection.style.display = "none";
mEpisodes.innerHTML = "";
// Subscribe-UI für diese Karte setzen
// Set subscribe UI for this card
applySubUI(lastClickedCard, loadSub(lastClickedCard));
// Status nochmal aktualisieren zur Sicherheit
// Refresh status again for safety
loadAllSubs().then(() => {
applySubUI(lastClickedCard, loadSub(lastClickedCard));
});
@@ -368,7 +399,7 @@
});
});
// ===== Subscribe-Button im Modal mit Backend-Sync =====
// ===== Subscribe button in modal with backend sync =====
if (subscribeBtn) {
subscribeBtn.addEventListener("click", async () => {
if (!lastClickedCard) return;
@@ -378,16 +409,16 @@
// Optimistic UI update
applySubUI(lastClickedCard, newState);
// Backend-Sync
// Backend sync
await saveSub(lastClickedCard, newState);
// Status neu laden zur Sicherheit
// Refresh status again for safety
const finalState = await loadSub(lastClickedCard);
applySubUI(lastClickedCard, finalState);
});
}
// ===== Datumsangaben in der Übersicht formatieren =====
// ===== Format date/time labels in the overview =====
document.querySelectorAll("[data-dt]").forEach(el => {
const v = el.getAttribute("data-dt");
if (!v) return;

View File

@@ -2,13 +2,17 @@ from django.urls import path
from .views import (
ArrIndexView, SeriesSubscribeView, SeriesUnsubscribeView,
MovieSubscribeView, MovieUnsubscribeView,
ListSeriesSubscriptionsView, ListMovieSubscriptionsView
ListSeriesSubscriptionsView, ListMovieSubscriptionsView,
CalendarView, CalendarEventsApi,
)
app_name = 'arr_api'
urlpatterns = [
path('', ArrIndexView.as_view(), name='index'),
# Calendar
path('calendar/', CalendarView.as_view(), name='calendar'),
path('api/calendar/events/', CalendarEventsApi.as_view(), name='calendar-events'),
# Series URLs
path('api/series/subscribe/<int:series_id>/', SeriesSubscribeView.as_view(), name='subscribe-series'),

View File

@@ -13,6 +13,7 @@ from rest_framework import status
from settingspanel.models import AppSettings
from .services import sonarr_calendar, radarr_calendar, ArrServiceError
from .models import SeriesSubscription, MovieSubscription
from django.utils import timezone
def _get_int(request, key, default):
@@ -67,13 +68,13 @@ class ArrIndexView(View):
try:
eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
except ArrServiceError as e:
messages.error(request, f"Sonarr nicht erreichbar: {e}")
messages.error(request, f"Sonarr is not reachable: {e}")
# Radarr robust laden
try:
movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
except ArrServiceError as e:
messages.error(request, f"Radarr nicht erreichbar: {e}")
messages.error(request, f"Radarr is not reachable: {e}")
# Suche
if q:
@@ -128,6 +129,74 @@ class ArrIndexView(View):
})
class CalendarView(View):
def get(self, request):
days = _get_int(request, "days", 60)
return render(request, "arr_api/calendar.html", {"days": days})
@method_decorator(login_required, name='dispatch')
class CalendarEventsApi(APIView):
def get(self, request):
days = _get_int(request, "days", 60)
conf = _arr_conf_from_db()
try:
eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
except ArrServiceError:
eps = []
try:
movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
except ArrServiceError:
movies = []
series_sub = set(SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True))
movie_sub_titles = set(MovieSubscription.objects.filter(user=request.user).values_list('title', flat=True))
events = []
for e in eps:
when = e.get("airDateUtc")
if not when:
continue
events.append({
"id": f"s:{e.get('seriesId')}:{e.get('episodeId')}",
"title": f"{e.get('seriesTitle','')} — S{e.get('seasonNumber')}E{e.get('episodeNumber')}",
"start": when,
"allDay": False,
"extendedProps": {
"kind": "series",
"seriesId": e.get('seriesId'),
"seriesTitle": e.get('seriesTitle'),
"seasonNumber": e.get('seasonNumber'),
"episodeNumber": e.get('episodeNumber'),
"episodeTitle": e.get('title'),
"overview": e.get('seriesOverview') or "",
"poster": e.get('seriesPoster') or "",
"subscribed": int(e.get('seriesId') or 0) in series_sub,
}
})
for m in movies:
when = m.get('digitalRelease') or m.get('physicalRelease') or m.get('inCinemas')
if not when:
continue
events.append({
"id": f"m:{m.get('movieId') or m.get('title')}",
"title": m.get('title') or "(movie)",
"start": when,
"allDay": True,
"extendedProps": {
"kind": "movie",
"movieId": m.get('movieId'),
"title": m.get('title'),
"overview": m.get('overview') or "",
"poster": m.get('posterUrl') or "",
"subscribed": (m.get('title') or '') in movie_sub_titles,
}
})
return Response({"events": events})
class SubscribeSeriesView(View):
@method_decorator(require_POST)
def post(self, request, series_id):
@@ -145,9 +214,9 @@ class SubscribeSeriesView(View):
)
if created:
messages.success(request, f'Serie "{series_data["series_title"]}" wurde abonniert!')
messages.success(request, f'Subscribed to series "{series_data["series_title"]}"!')
else:
messages.info(request, f'Serie "{series_data["series_title"]}" war bereits abonniert.')
messages.info(request, f'Series "{series_data["series_title"]}" was already subscribed.')
return redirect('arr_api:index')
@@ -157,7 +226,7 @@ class UnsubscribeSeriesView(View):
subscription = get_object_or_404(SeriesSubscription, series_id=series_id)
series_title = subscription.series_title
subscription.delete()
messages.success(request, f'Abonnement für "{series_title}" wurde beendet.')
messages.success(request, f'Subscription for "{series_title}" has been removed.')
return redirect('arr_api:index')
class SubscribeMovieView(View):
@@ -178,9 +247,9 @@ class SubscribeMovieView(View):
)
if created:
messages.success(request, f'Film "{movie_data["title"]}" wurde abonniert!')
messages.success(request, f'Subscribed to movie "{movie_data["title"]}"!')
else:
messages.info(request, f'Film "{movie_data["title"]}" war bereits abonniert.')
messages.info(request, f'Movie "{movie_data["title"]}" was already subscribed.')
return redirect('arr_api:index')
@@ -190,14 +259,14 @@ class UnsubscribeMovieView(View):
subscription = get_object_or_404(MovieSubscription, movie_id=movie_id)
movie_title = subscription.title
subscription.delete()
messages.success(request, f'Abonnement für "{movie_title}" wurde beendet.')
messages.success(request, f'Subscription for "{movie_title}" has been removed.')
return redirect('arr_api:index')
@require_POST
@login_required
def subscribe_series(request, series_id):
"""Serie abonnieren"""
"""Subscribe to a series"""
try:
# Existiert bereits?
if SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists():
@@ -224,7 +293,7 @@ def subscribe_series(request, series_id):
@require_POST
@login_required
def unsubscribe_series(request, series_id):
"""Serie deabonnieren"""
"""Unsubscribe from a series"""
try:
SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete()
return JsonResponse({'success': True})
@@ -233,14 +302,14 @@ def unsubscribe_series(request, series_id):
@login_required
def is_subscribed_series(request, series_id):
"""Prüfe ob Serie abonniert ist"""
"""Check if a series is subscribed"""
is_subbed = SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists()
return JsonResponse({'subscribed': is_subbed})
@require_POST
@login_required
def subscribe_movie(request, movie_id):
"""Film abonnieren"""
"""Subscribe to a movie"""
try:
# Existiert bereits?
if MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists():
@@ -268,7 +337,7 @@ def subscribe_movie(request, movie_id):
@require_POST
@login_required
def unsubscribe_movie(request, movie_id):
"""Film deabonnieren"""
"""Unsubscribe from a movie"""
try:
MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).delete()
return JsonResponse({'success': True})
@@ -277,13 +346,13 @@ def unsubscribe_movie(request, movie_id):
@login_required
def is_subscribed_movie(request, movie_id):
"""Prüfe ob Film abonniert ist"""
"""Check if a movie is subscribed"""
is_subbed = MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists()
return JsonResponse({'subscribed': is_subbed})
@login_required
def get_subscriptions(request):
"""Hole alle Abonnements des Users"""
"""Get all subscriptions for the user"""
series = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True)
movies = MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True)
return JsonResponse({

View File

@@ -4,7 +4,7 @@ services:
build: .
container_name: subscribarr
ports:
- "8000:8000"
- "8081:8000"
environment:
# Django
- DJANGO_DEBUG=true
@@ -13,22 +13,22 @@ services:
- DB_PATH=/app/data/db.sqlite3
- NOTIFICATIONS_ALLOW_DUPLICATES=false
# App Settings (optional, otherwise use first-run setup)
- JELLYFIN_URL=
- JELLYFIN_API_KEY=
- SONARR_URL=
- SONARR_API_KEY=
- RADARR_URL=
- RADARR_API_KEY=
- MAIL_HOST=
- MAIL_PORT=
- MAIL_SECURE=
- MAIL_USER=
- MAIL_PASSWORD=
- MAIL_FROM=
#- JELLYFIN_URL=
#- JELLYFIN_API_KEY=
#- SONARR_URL=
#- SONARR_API_KEY=
#- RADARR_URL=
#- RADARR_API_KEY=
#- MAIL_HOST=
#- MAIL_PORT=
#- MAIL_SECURE=
#- MAIL_USER=
#- MAIL_PASSWORD=
#- MAIL_FROM=
# Admin bootstrap (optional)
- ADMIN_USERNAME=
- ADMIN_PASSWORD=
- ADMIN_EMAIL=
#- ADMIN_USERNAME=
#- ADMIN_PASSWORD=
#- ADMIN_EMAIL=
# Cron schedule (default every 30min)
- CRON_SCHEDULE=*/30 * * * *
volumes:

View File

@@ -7,20 +7,20 @@ class FirstRunSetupForm(forms.Form):
jellyfin_server_url = forms.URLField(
label="Jellyfin Server URL",
required=True,
help_text="Die URL deines Jellyfin-Servers"
help_text="URL of your Jellyfin server"
)
jellyfin_api_key = forms.CharField(
label="Jellyfin API Key",
required=True,
widget=forms.PasswordInput(render_value=True),
help_text="Der API-Key aus den Jellyfin-Einstellungen"
help_text="API key from Jellyfin settings"
)
# Sonarr (Optional)
sonarr_url = forms.URLField(
label="Sonarr URL",
required=False,
help_text="Die URL deines Sonarr-Servers"
help_text="URL of your Sonarr server"
)
sonarr_api_key = forms.CharField(
label="Sonarr API Key",
@@ -32,7 +32,7 @@ class FirstRunSetupForm(forms.Form):
radarr_url = forms.URLField(
label="Radarr URL",
required=False,
help_text="Die URL deines Radarr-Servers"
help_text="URL of your Radarr server"
)
radarr_api_key = forms.CharField(
label="Radarr API Key",
@@ -45,13 +45,13 @@ class JellyfinSettingsForm(forms.Form):
label="Jellyfin Server URL",
required=False,
widget=forms.URLInput(attrs=WIDE),
help_text="z.B. http://localhost:8096"
help_text="e.g. http://localhost:8096"
)
jellyfin_api_key = forms.CharField(
label="Jellyfin API Key",
required=False,
widget=forms.PasswordInput(render_value=True, attrs=WIDE),
help_text="Admin API Key aus den Jellyfin Einstellungen"
help_text="Admin API key from Jellyfin settings"
)
class ArrSettingsForm(forms.Form):
@@ -68,18 +68,18 @@ class MailSettingsForm(forms.Form):
mail_host = forms.CharField(label="Mail Host", required=False)
mail_port = forms.IntegerField(label="Mail Port", required=False, min_value=1, max_value=65535)
mail_secure = forms.ChoiceField(
label="Sicherheit", required=False,
choices=[("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")]
label="Security", required=False,
choices=[("", "No TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")]
)
mail_user = forms.CharField(label="Mail Benutzer", required=False)
mail_user = forms.CharField(label="Mail Username", required=False)
mail_password = forms.CharField(
label="Mail Passwort", required=False,
label="Mail Password", required=False,
widget=forms.PasswordInput(render_value=True)
)
mail_from = forms.EmailField(label="Absender (From)", required=False)
mail_from = forms.EmailField(label="Sender (From)", required=False)
class AccountForm(forms.Form):
username = forms.CharField(label="Benutzername", required=False)
email = forms.EmailField(label="E-Mail", required=False)
new_password = forms.CharField(label="Neues Passwort", required=False, widget=forms.PasswordInput)
repeat_password = forms.CharField(label="Passwort wiederholen", required=False, widget=forms.PasswordInput)
username = forms.CharField(label="Username", required=False)
email = forms.EmailField(label="Email", required=False)
new_password = forms.CharField(label="New password", required=False, widget=forms.PasswordInput)
repeat_password = forms.CharField(label="Repeat password", required=False, widget=forms.PasswordInput)

View File

@@ -1,7 +1,7 @@
from django.db import models
class AppSettings(models.Model):
# Singleton-Pattern über feste ID
# Singleton pattern via fixed ID
singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
# Jellyfin
@@ -20,7 +20,7 @@ class AppSettings(models.Model):
mail_secure = models.CharField(
max_length=10, blank=True, null=True,
choices=(
("", "Kein TLS/SSL"),
("", "No TLS/SSL"),
("starttls", "STARTTLS (Port 587)"),
("ssl", "SSL/TLS (Port 465)"),
("tls", "TLS (alias STARTTLS)"),
@@ -30,7 +30,7 @@ class AppSettings(models.Model):
mail_password = models.CharField(max_length=255, blank=True, null=True)
mail_from = models.EmailField(blank=True, null=True)
# Account
# Account
acc_username = models.CharField(max_length=150, blank=True, null=True)
acc_email = models.EmailField(blank=True, null=True)

View File

@@ -7,28 +7,28 @@
{% block content %}
<div class="setup-container">
<h1>Willkommen bei Subscribarr</h1>
<p class="setup-intro">Lass uns deine Installation einrichten. Du brauchst mindestens einen Jellyfin-Server.</p>
<h1>Welcome to Subscribarr</h1>
<p class="setup-intro">Let's set up your installation. You need at least one Jellyfin server.</p>
<form method="post" class="setup-form">
{% csrf_token %}
<div class="setup-section">
<h2>Jellyfin Server (Erforderlich)</h2>
<h2>Jellyfin server (required)</h2>
<div class="form-group">
<label>Server URL</label>
{{ form.jellyfin_server_url }}
<div class="help">z.B. http://192.168.1.100:8096 oder http://jellyfin.local:8096</div>
<div class="help">e.g., http://192.168.1.100:8096 or http://jellyfin.local:8096</div>
</div>
<div class="form-group">
<label>API Key</label>
{{ form.jellyfin_api_key }}
<div class="help">Admin API Key aus den Jellyfin-Einstellungen</div>
<div class="help">Admin API key from Jellyfin settings</div>
</div>
</div>
<div class="setup-section">
<h2>Sonarr (Optional)</h2>
<h2>Sonarr (optional)</h2>
<div class="form-group">
<label>Server URL</label>
{{ form.sonarr_url }}
@@ -40,7 +40,7 @@
</div>
<div class="setup-section">
<h2>Radarr (Optional)</h2>
<h2>Radarr (optional)</h2>
<div class="form-group">
<label>Server URL</label>
{{ form.radarr_url }}
@@ -51,7 +51,7 @@
</div>
</div>
<button type="submit" class="setup-submit">Installation abschließen</button>
<button type="submit" class="setup-submit">Finish setup</button>
</form>
</div>
{% endblock %}

View File

@@ -1,19 +1,22 @@
{% load static %}
<!doctype html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Einstellungen Subscribarr</title>
<title>Settings Subscribarr</title>
<link rel="stylesheet" href="{% static 'css/settings.css' %}">
</head>
<body>
<div class="wrap">
<div class="topbar">
<div><a href="/" class="btn">← Zurück</a></div>
<div><strong>Einstellungen</strong></div>
<div style="display:flex; gap:8px; align-items:center;">
<a href="/" class="btn">← Back</a>
<a href="{% url 'settingspanel:subscriptions' %}" class="btn">👥 Subscriptions</a>
</div>
<div><strong>Settings</strong></div>
<div></div>
</div>
@@ -29,12 +32,12 @@
<div class="row">
<label>Jellyfin Server URL</label>
{{ jellyfin_form.jellyfin_server_url }}
<div class="help">z.B. http://localhost:8096</div>
<div class="help">e.g., http://localhost:8096</div>
</div>
<div class="row">
<label>Jellyfin API Key</label>
{{ jellyfin_form.jellyfin_api_key }}
<div class="help">Admin API Key aus den Jellyfin Einstellungen</div>
<div class="help">Admin API key from your Jellyfin settings</div>
</div>
</div>
@@ -46,8 +49,7 @@
<div class="inline">
<div class="field">{{ arr_form.sonarr_url }}</div>
<div class="inline-actions">
<button class="btn" type="button" onclick="testConnection('sonarr', this)">Test
Sonarr</button>
<button class="btn" type="button" onclick="testConnection('sonarr', this)">Test Sonarr</button>
<span id="sonarrStatus" class="badge muted"></span>
</div>
</div>
@@ -64,8 +66,7 @@
<div class="inline">
<div class="field">{{ arr_form.radarr_url }}</div>
<div class="inline-actions">
<button class="btn" type="button" onclick="testConnection('radarr', this)">Test
Radarr</button>
<button class="btn" type="button" onclick="testConnection('radarr', this)">Test Radarr</button>
<span id="radarrStatus" class="badge muted"></span>
</div>
</div>
@@ -76,32 +77,31 @@
{{ arr_form.radarr_api_key }}
</div>
<div class="help">Klicke „Test …“, um die Verbindung gegen <code>/api/v3/system/status</code> zu
prüfen.</div>
<div class="help">ClickTest …” to verify against <code>/api/v3/system/status</code>.</div>
</div>
<div class="card">
<h2>Mailserver</h2>
<h2>Mail server</h2>
<div class="row"><label>Host</label>{{ mail_form.mail_host }}</div>
<div class="row"><label>Port</label>{{ mail_form.mail_port }}</div>
<div class="row"><label>Sicherheit</label>{{ mail_form.mail_secure }}</div>
<div class="row"><label>Benutzer</label>{{ mail_form.mail_user }}</div>
<div class="row"><label>Passwort</label>{{ mail_form.mail_password }}</div>
<div class="row"><label>Absender</label>{{ mail_form.mail_from }}</div>
<div class="row"><label>Security</label>{{ mail_form.mail_secure }}</div>
<div class="row"><label>User</label>{{ mail_form.mail_user }}</div>
<div class="row"><label>Password</label>{{ mail_form.mail_password }}</div>
<div class="row"><label>From</label>{{ mail_form.mail_from }}</div>
</div>
<div class="card">
<h2>Konto</h2>
<div class="row"><label>Benutzername</label>{{ account_form.username }}</div>
<div class="row"><label>E-Mail</label>{{ account_form.email }}</div>
<div class="row"><label>Neues Passwort</label>{{ account_form.new_password }}</div>
<div class="row"><label>Passwort wiederholen</label>{{ account_form.repeat_password }}</div>
<div class="help">Nur Oberfläche Umsetzung Passwortänderung später.</div>
<h2>Account</h2>
<div class="row"><label>Username</label>{{ account_form.username }}</div>
<div class="row"><label>Email</label>{{ account_form.email }}</div>
<div class="row"><label>New password</label>{{ account_form.new_password }}</div>
<div class="row"><label>Repeat password</label>{{ account_form.repeat_password }}</div>
<div class="help">Only UI implementing password change later.</div>
</div>
</div>
<div style="margin-top:16px">
<button class="btn btn-primary" type="submit">Speichern</button>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div>
@@ -116,25 +116,25 @@
.then(r => r.json())
.then(data => {
if (data.ok) {
alert(kind + " Verbindung erfolgreich!");
alert(kind + " connection successful!");
} else {
alert(kind + " Fehler: " + data.error);
alert(kind + " error: " + data.error);
}
})
.catch(err => alert(kind + " Fehler: " + err));
.catch(err => alert(kind + " error: " + err));
}
function setBadge(kind, state, text, tooltip) {
const el = document.getElementById(kind + "Status");
if (!el) return;
el.classList.remove("ok", "err", "muted");
el.title = tooltip || ""; // voller Fehlertext im Tooltip
el.title = tooltip || ""; // full error text in tooltip
if (state === "ok") {
el.classList.add("ok");
el.textContent = "Verbunden";
el.textContent = "Connected";
} else if (state === "err") {
el.classList.add("err");
el.textContent = "Fehler";
el.textContent = "Error";
} else {
el.classList.add("muted");
el.textContent = "—";
@@ -147,19 +147,19 @@
const url = urlEl ? urlEl.value.trim() : "";
const key = keyEl ? keyEl.value.trim() : "";
setBadge(kind, "muted", "Teste…");
setBadge(kind, "muted", "Testing…");
if (btnEl) { btnEl.disabled = true; }
fetch(`/settings/test-connection/?kind=${encodeURIComponent(kind)}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`)
.then(r => r.json())
.then(data => {
if (data.ok) {
setBadge(kind, "ok", "Verbunden", "");
setBadge(kind, "ok", "Connected", "");
} else {
setBadge(kind, "err", "Fehler", data.error || "Unbekannter Fehler");
setBadge(kind, "err", "Error", data.error || "Unknown error");
}
})
.catch(err => setBadge(kind, "err", "Fehler", String(err)))
.catch(err => setBadge(kind, "err", "Error", String(err)))
.finally(() => { if (btnEl) btnEl.disabled = false; });
}

View 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">&times;</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 %}

View File

@@ -1,9 +1,10 @@
from django.urls import path
from .views import SettingsView, test_connection, first_run
from .views import SettingsView, test_connection, first_run, subscriptions_overview
app_name = "settingspanel"
urlpatterns = [
path("", SettingsView.as_view(), name="index"),
path("test-connection/", test_connection, name="test_connection"),
path("setup/", first_run, name="setup"),
path("subscriptions/", subscriptions_overview, name="subscriptions"),
]

View File

@@ -7,6 +7,8 @@ from .models import AppSettings
from django.http import JsonResponse
from accounts.utils import jellyfin_admin_required
from django.contrib.auth import get_user_model
from arr_api.models import SeriesSubscription, MovieSubscription
from django.db.models import Count
import requests
def needs_setup():
@@ -32,7 +34,7 @@ def first_run(request):
settings.radarr_api_key = form.cleaned_data['radarr_api_key']
settings.save()
messages.success(request, 'Setup erfolgreich abgeschlossen!')
messages.success(request, 'Setup completed successfully!')
return redirect('accounts:login')
else:
form = FirstRunSetupForm()
@@ -53,9 +55,9 @@ def test_connection(request):
url = (request.GET.get("url") or "").strip()
key = (request.GET.get("key") or "").strip()
if kind not in ("sonarr", "radarr"):
return JsonResponse({"ok": False, "error": "Ungültiger Typ"}, status=400)
return JsonResponse({"ok": False, "error": "Invalid type"}, status=400)
if not url or not key:
return JsonResponse({"ok": False, "error": "URL und API-Key erforderlich"}, status=400)
return JsonResponse({"ok": False, "error": "URL and API key required"}, status=400)
try:
r = requests.get(
@@ -111,29 +113,81 @@ class SettingsView(View):
"jellyfin_form": jellyfin_form,
"arr_form": arr_form,
"mail_form": mail_form,
"account_form": acc_form
"account_form": acc_form,
})
cfg = AppSettings.current()
# Update Jellyfin settings
cfg.jellyfin_server_url = jellyfin_form.cleaned_data["jellyfin_server_url"] or None
cfg.jellyfin_api_key = jellyfin_form.cleaned_data["jellyfin_api_key"] or None
cfg.sonarr_url = arr_form.cleaned_data["sonarr_url"] or None
cfg.sonarr_api_key = arr_form.cleaned_data["sonarr_api_key"] or None
cfg.radarr_url = arr_form.cleaned_data["radarr_url"] or None
cfg.radarr_api_key = arr_form.cleaned_data["radarr_api_key"] or None
cfg.jellyfin_server_url = jellyfin_form.cleaned_data.get("jellyfin_server_url") or None
cfg.jellyfin_api_key = jellyfin_form.cleaned_data.get("jellyfin_api_key") or None
cfg.mail_host = mail_form.cleaned_data["mail_host"] or None
cfg.mail_port = mail_form.cleaned_data["mail_port"] or None
cfg.mail_secure = mail_form.cleaned_data["mail_secure"] or ""
cfg.mail_user = mail_form.cleaned_data["mail_user"] or None
cfg.mail_password = mail_form.cleaned_data["mail_password"] or None
cfg.mail_from = mail_form.cleaned_data["mail_from"] or None
# Update Sonarr/Radarr settings
cfg.sonarr_url = arr_form.cleaned_data.get("sonarr_url") or None
cfg.sonarr_api_key = arr_form.cleaned_data.get("sonarr_api_key") or None
cfg.radarr_url = arr_form.cleaned_data.get("radarr_url") or None
cfg.radarr_api_key = arr_form.cleaned_data.get("radarr_api_key") or None
cfg.acc_username = acc_form.cleaned_data["username"] or None
cfg.acc_email = acc_form.cleaned_data["email"] or None
# Update Mail settings
cfg.mail_host = mail_form.cleaned_data.get("mail_host") or None
cfg.mail_port = mail_form.cleaned_data.get("mail_port") or None
cfg.mail_secure = mail_form.cleaned_data.get("mail_secure") or ""
cfg.mail_user = mail_form.cleaned_data.get("mail_user") or None
cfg.mail_password = mail_form.cleaned_data.get("mail_password") or None
cfg.mail_from = mail_form.cleaned_data.get("mail_from") or None
# Update account settings
cfg.acc_username = acc_form.cleaned_data.get("username") or None
cfg.acc_email = acc_form.cleaned_data.get("email") or None
cfg.save()
messages.success(request, "Einstellungen gespeichert (DB).")
messages.success(request, "Settings saved (DB).")
return redirect("settingspanel:index")
@jellyfin_admin_required
def subscriptions_overview(request):
series = SeriesSubscription.objects.select_related('user').order_by('user__username', 'series_title')
movies = MovieSubscription.objects.select_related('user').order_by('user__username', 'title')
# Aggregate counts per user
s_counts = SeriesSubscription.objects.values('user_id', 'user__username').annotate(series_count=Count('id'))
m_counts = MovieSubscription.objects.values('user_id', 'user__username').annotate(movie_count=Count('id'))
user_map = {}
for row in s_counts:
key = row['user_id']
user_map.setdefault(key, {
'user_id': key,
'username': row['user__username'],
'series_count': 0,
'movie_count': 0,
})
user_map[key]['series_count'] = row['series_count']
for row in m_counts:
key = row['user_id']
user_map.setdefault(key, {
'user_id': key,
'username': row['user__username'],
'series_count': 0,
'movie_count': 0,
})
user_map[key]['movie_count'] = row['movie_count']
user_stats = []
for key, val in user_map.items():
total = (val.get('series_count') or 0) + (val.get('movie_count') or 0)
user_stats.append({
'user_id': val['user_id'],
'username': val['username'],
'username_lower': (val['username'] or '').lower(),
'series_count': val.get('series_count') or 0,
'movie_count': val.get('movie_count') or 0,
'total_count': total,
})
user_stats.sort(key=lambda x: (-x['total_count'], x['username'].lower()))
return render(request, 'settingspanel/subscriptions.html', {
'series': series,
'movies': movies,
'user_stats': user_stats,
})

View File

@@ -120,6 +120,27 @@ body {
align-items: center;
}
.nav-toggle {
display: none;
background: transparent;
border: 1px solid var(--panel-b);
color: var(--text);
padding: 8px;
border-radius: 8px;
cursor: pointer;
}
.nav-toggle span { display:block; width:20px; height:2px; background: var(--text); margin:4px 0; }
@media (max-width: 768px) {
.nav-content { flex-wrap: wrap; gap: 10px; }
.nav-toggle { display: inline-block; }
.nav-links { display: none; width: 100%; flex-direction: column; align-items: stretch; gap: 6px; }
.nav-links.open { display: flex; }
.nav-links a, .nav-links button, .nav-links .inline-form { width: 100%; }
.nav-links a, .nav-links button { justify-content: flex-start; padding: 10px 12px; border: 1px solid var(--panel-b); border-radius: 10px; }
.user-info { order: -1; margin: 0 0 4px 0; }
}
.nav-links a,
.nav-links button {
display: flex;
@@ -151,9 +172,9 @@ body {
background: var(--accent);
}
.nav-logout {
color: #ef4444 !important;
}
.nav-logout { color: #ef4444 !important; }
.btn-link { background: transparent; border: 1px solid var(--panel-b); border-radius: 8px; }
.btn-link:hover { background: rgba(255,255,255,0.06); }
.nav-admin {
color: var(--accent) !important;

View File

@@ -39,6 +39,14 @@ h1 {
margin-bottom: 16px
}
@media (max-width: 640px) {
.controls { flex-direction: column; align-items: stretch; }
.controls form { width: 100%; }
.controls .seg { width: 100%; justify-content: space-between; }
.controls-actions { width: 100%; }
.controls-actions .btn { width: 100%; justify-content: center; }
}
.controls form {
display: flex;
align-items: center;
@@ -67,6 +75,24 @@ h1 {
cursor: pointer
}
/* Calendar button */
.controls-actions .btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #2a2b44;
background: linear-gradient(180deg, #19213b 0%, #141a30 100%);
color: #e6e6e6;
text-decoration: none;
font-weight: 650;
box-shadow: 0 6px 18px rgba(0,0,0,.25);
transition: transform .08s ease, filter .12s ease, border-color .12s ease;
}
.controls-actions .btn:hover { filter: brightness(1.08); border-color: #3b4aa0; transform: translateY(-1px); }
.controls-actions .btn:active { transform: translateY(0); }
.seg {
display: inline-flex;
background: #0f0f17;
@@ -99,6 +125,10 @@ h1 {
}
}
@media (max-width: 640px) {
.grid { grid-template-columns: 1fr; gap: 12px; }
}
.card {
background: var(--panel);
border: 1px solid var(--panel-b);
@@ -111,6 +141,12 @@ h1 {
cursor: pointer
}
@media (max-width: 640px) {
.card { gap: 10px; }
.poster { width: 90px; height: 135px; }
.title { font-size: .98rem; }
}
.card:active,
.card:hover {
transform: translateY(-2px);
@@ -260,6 +296,12 @@ h1 {
border-bottom: 1px solid #20223a
}
@media (max-width: 640px) {
.modal-header { grid-template-columns: 90px 1fr 36px; gap: 10px; }
.m-poster { width: 90px; height: 135px; }
.m-title { font-size: 1.05rem; }
}
.m-poster {
display: flex;
flex-direction: column;
@@ -344,6 +386,10 @@ h1 {
}
}
@media (max-width: 640px) {
.modal-body { grid-template-columns: 1fr; }
}
.section-block {
background: #101327;
border: 1px solid #20223a;

View File

@@ -1,6 +1,6 @@
{% load static %}
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8">
@@ -14,15 +14,18 @@
<nav class="main-nav">
<div class="nav-content">
<a href="/" class="nav-brand">Subscribarr</a>
<div class="nav-links">
<button class="nav-toggle" aria-label="Open menu" aria-expanded="false" aria-controls="nav-menu">
<span></span><span></span><span></span>
</button>
<div id="nav-menu" class="nav-links">
{% if user.is_authenticated %}
<span class="user-info">Angemeldet als <strong>{{ user.username }}</strong></span>
<span class="user-info">Signed in as <strong>{{ user.username }}</strong></span>
<a href="{% url 'accounts:profile' %}" class="nav-profile">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
Profil
Profile
</a>
{% if user.is_jellyfin_admin %}
<a href="{% url 'settingspanel:index' %}" class="nav-admin">
@@ -34,14 +37,14 @@
{% endif %}
<form method="post" action="{% url 'accounts:logout' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="nav-logout">
<button type="submit" class="nav-logout btn-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Abmelden
Sign out
</button>
</form>
{% else %}
@@ -51,7 +54,7 @@
<polyline points="10 17 15 12 10 7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
Anmelden
Sign in
</a>
<a href="{% url 'accounts:register' %}" class="nav-register">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -60,13 +63,25 @@
<line x1="20" y1="8" x2="20" y2="14"></line>
<line x1="23" y1="11" x2="17" y2="11"></line>
</svg>
Registrieren
Register
</a>
{% endif %}
</div>
</div>
</nav>
<script>
(function(){
const btn = document.querySelector('.nav-toggle');
const menu = document.getElementById('nav-menu');
if(!btn || !menu) return;
btn.addEventListener('click', ()=>{
const open = menu.classList.toggle('open');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
})();
</script>
{% block content %}{% endblock %}
</body>