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

@@ -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() {
@@ -207,7 +212,7 @@
return k ? subCache.get(k) || false : false;
}
async function saveSub(card, on) {
async function saveSub(card, on) {
const k = subKey(card);
if (!k) return;
const [type, id] = k.split(":");
@@ -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({