usermanagement/translation/calendar
This commit is contained in:
@@ -3,12 +3,12 @@ from django.utils import timezone
|
||||
from arr_api.notifications import check_and_notify_users
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Prüft neue Medien und sendet Benachrichtigungen'
|
||||
help = 'Checks for new media and sends notifications'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
self.stdout.write(f'[{timezone.now()}] Starte Medien-Check...')
|
||||
self.stdout.write(f'[{timezone.now()}] Starting media check...')
|
||||
try:
|
||||
check_and_notify_users()
|
||||
self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Medien-Check erfolgreich beendet'))
|
||||
self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Media check finished successfully'))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Fehler beim Medien-Check: {str(e)}'))
|
||||
self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Error during media check: {str(e)}'))
|
||||
|
@@ -12,7 +12,7 @@ class SeriesSubscription(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['user', 'series_id'] # Ein User kann eine Serie nur einmal abonnieren
|
||||
unique_together = ['user', 'series_id'] # A user can subscribe to a series only once
|
||||
|
||||
def __str__(self):
|
||||
return self.series_title
|
||||
@@ -29,18 +29,16 @@ class MovieSubscription(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['user', 'movie_id'] # Ein User kann einen Film nur einmal abonnieren
|
||||
unique_together = ['user', 'movie_id'] # A user can subscribe to a movie only once
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class SentNotification(models.Model):
|
||||
"""
|
||||
Speichert gesendete Benachrichtigungen um Duplikate zu vermeiden
|
||||
"""
|
||||
"""Store sent notifications to avoid duplicates"""
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
media_id = models.IntegerField()
|
||||
media_type = models.CharField(max_length=10) # 'series' oder 'movie'
|
||||
media_type = models.CharField(max_length=10) # 'series' or 'movie'
|
||||
media_title = models.CharField(max_length=255)
|
||||
air_date = models.DateField()
|
||||
sent_at = models.DateTimeField(auto_now_add=True)
|
||||
|
@@ -68,7 +68,7 @@ def send_notification_email(
|
||||
release_type=None,
|
||||
):
|
||||
"""
|
||||
Sendet eine Benachrichtigungs-E-Mail an einen User mit erweiterten Details
|
||||
Sends a notification email to a user with extended details
|
||||
"""
|
||||
eff = _set_runtime_email_settings()
|
||||
logger.info(
|
||||
@@ -94,7 +94,7 @@ def send_notification_email(
|
||||
context = {
|
||||
'username': user.username,
|
||||
'title': media_title,
|
||||
'type': 'Serie' if media_type == 'series' else 'Film',
|
||||
'type': 'Series' if media_type == 'series' else 'Movie',
|
||||
'overview': overview,
|
||||
'poster_url': poster_url,
|
||||
'episode_title': episode_title,
|
||||
@@ -105,7 +105,7 @@ def send_notification_email(
|
||||
'release_type': release_type,
|
||||
}
|
||||
|
||||
subject = f"Neue {context['type']} verfügbar: {media_title}"
|
||||
subject = f"New {context['type']} available: {media_title}"
|
||||
message = render_to_string('arr_api/email/new_media_notification.html', context)
|
||||
|
||||
send_mail(
|
||||
@@ -209,8 +209,8 @@ def get_todays_radarr_calendar():
|
||||
|
||||
def check_jellyfin_availability(user, media_id, media_type):
|
||||
"""
|
||||
Ersetzt: Wir prüfen Verfügbarkeit über Sonarr/Radarr (hasFile),
|
||||
was zuverlässig ist, wenn Jellyfin dieselben Ordner scannt.
|
||||
Replaced: We check availability via Sonarr/Radarr (hasFile),
|
||||
which is reliable if Jellyfin scans the same folders.
|
||||
"""
|
||||
# user is unused here; kept for backward compatibility
|
||||
if media_type == 'series':
|
||||
|
301
arr_api/templates/arr_api/calendar.html
Normal file
301
arr_api/templates/arr_api/calendar.html
Normal file
@@ -0,0 +1,301 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Calendar – Subscribarr{% endblock %}
|
||||
|
||||
{% block extra_style %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3fb950; /* Subscribarr green */
|
||||
--bg-soft: #0f172a;
|
||||
--card: #0b1224;
|
||||
--text: #e6edf3;
|
||||
--muted: #94a3b8;
|
||||
--border: #1f2a44;
|
||||
}
|
||||
.wrap { max-width: 1200px; margin: 0 auto; padding: 12px; }
|
||||
h1 { margin: 10px 0 18px; font-size: 22px; }
|
||||
|
||||
/* FullCalendar theme tweaks */
|
||||
.fc { --fc-border-color: var(--border); color: var(--text); }
|
||||
.fc-theme-standard { --fc-page-bg-color: var(--card); --fc-neutral-bg-color: #0f172a; }
|
||||
.fc .fc-scrollgrid, .fc .fc-scrollgrid-section > td { background: var(--card); }
|
||||
.fc .fc-col-header, .fc .fc-col-header-cell { background: #0f172a; }
|
||||
.fc .fc-daygrid-day, .fc .fc-timegrid-slot { background: transparent; }
|
||||
.fc .fc-toolbar { gap: 8px; margin-bottom: 12px; display: flex; flex-wrap: wrap; }
|
||||
.fc .fc-toolbar-title { font-size: 18px; }
|
||||
.fc .fc-button { background: #121b33; border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 8px; }
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
.fc .fc-button-primary:not(:disabled):active { background: #1a2542; border-color: var(--border); }
|
||||
.fc .fc-button:hover { filter: brightness(1.1); }
|
||||
.fc .fc-button:focus { box-shadow: 0 0 0 2px rgba(63,185,80,.35); }
|
||||
.fc .fc-daygrid-day-number { color: var(--muted); }
|
||||
.fc .fc-day-today { background: rgba(63,185,80,0.08); }
|
||||
.fc .fc-col-header-cell-cushion { color: var(--muted); }
|
||||
|
||||
.fc .fc-daygrid-event, .fc .fc-timegrid-event, .fc .fc-list-event { cursor: pointer; }
|
||||
.fc .fc-daygrid-event { border-radius: 6px; padding: 2px 4px; border: 1px solid var(--border); }
|
||||
.fc .subscribed-event { border-left: 4px solid var(--primary) !important; background: rgba(63,185,80,0.06); }
|
||||
.fc .event-series { border-left-color: #60a5fa; }
|
||||
.fc .event-movie { border-left-color: #f59e0b; }
|
||||
.fc .fc-list {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
.fc .fc-list-event:hover { background: rgba(255,255,255,0.03); }
|
||||
.event-poster { width: 24px; height: 36px; object-fit: cover; border-radius: 2px; margin-right: 6px; }
|
||||
#calendar { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 100%; }
|
||||
|
||||
/* Compact tweaks for phones */
|
||||
@media (max-width: 640px) {
|
||||
.fc .fc-toolbar-title { font-size: 16px; }
|
||||
.fc .fc-button { padding: 5px 8px; border-radius: 8px; }
|
||||
.fc .fc-col-header-cell-cushion { font-size: 12px; }
|
||||
.fc .fc-daygrid-day-number { font-size: 12px; }
|
||||
.fc .fc-daygrid-event { font-size: 12px; padding: 1px 3px; }
|
||||
.event-poster { width: 20px; height: 30px; }
|
||||
}
|
||||
|
||||
/* Modal minor polish in this page */
|
||||
.modal { background: #0b1224; border: 1px solid var(--border); }
|
||||
.btn-subscribe { background: var(--primary); color: #06210e; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="{% static 'css/index.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="wrap">
|
||||
<h1>Calendar</h1>
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modal (same as on the homepage) -->
|
||||
<div id="modalBackdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-hidden="true" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="m-poster-wrap">
|
||||
<div class="m-poster"><img id="mPoster" alt=""></div>
|
||||
<button id="subscribeBtn" class="btn-subscribe" type="button">Subscribe</button>
|
||||
</div>
|
||||
<div>
|
||||
<div id="mTitle" class="m-title"></div>
|
||||
<div id="mSub" class="m-sub"></div>
|
||||
<div id="mBadges" class="badges"></div>
|
||||
</div>
|
||||
<button class="modal-close" title="Close" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="section-block">
|
||||
<div class="section-title">Overview</div>
|
||||
<div id="mOverview" class="desc muted"></div>
|
||||
</div>
|
||||
<div class="section-block">
|
||||
<div class="section-title">Upcoming episodes</div>
|
||||
<div class="section-divider"></div>
|
||||
<div id="mEpisodes"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% csrf_token %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const backdrop = $("#modalBackdrop");
|
||||
const closeBtn = backdrop.querySelector(".modal-close");
|
||||
const mPoster = $("#mPoster");
|
||||
const mTitle = $("#mTitle");
|
||||
const mOverview = $("#mOverview");
|
||||
const mEpisodes = $("#mEpisodes");
|
||||
const mSub = $("#mSub");
|
||||
const subscribeBtn = $("#subscribeBtn");
|
||||
const epSection = mEpisodes.closest(".section-block");
|
||||
const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
const bc = ('BroadcastChannel' in window) ? new BroadcastChannel('subscribarr-sub') : null;
|
||||
|
||||
function openModal(){ backdrop.style.display = 'flex'; backdrop.setAttribute('aria-hidden','false'); document.body.style.overflow='hidden'; }
|
||||
function closeModal(){ backdrop.style.display = 'none'; backdrop.setAttribute('aria-hidden','true'); document.body.style.overflow=''; }
|
||||
closeBtn.addEventListener('click', closeModal);
|
||||
backdrop.addEventListener('click', (e)=>{ if(e.target===backdrop) closeModal(); });
|
||||
window.addEventListener('keydown', e=>{ if(e.key==='Escape') closeModal(); });
|
||||
|
||||
// subscription cache
|
||||
const subCache = new Map();
|
||||
async function loadAllSubs(){
|
||||
try {
|
||||
const [s,m] = await Promise.all([
|
||||
fetch('/api/series/subscriptions/'),
|
||||
fetch('/api/movies/subscriptions/')
|
||||
]);
|
||||
if(s.ok){ (await s.json()).forEach(id => subCache.set(`series:${id}`, true)); }
|
||||
if(m.ok){ (await m.json()).forEach(title => subCache.set(`movie:${title}`, true)); }
|
||||
} catch(err){ console.warn('subs load failed', err); }
|
||||
}
|
||||
function isSub(kind, idOrTitle){
|
||||
const key = kind==='series' ? `series:${idOrTitle}` : `movie:${idOrTitle}`;
|
||||
return subCache.has(key);
|
||||
}
|
||||
async function toggleSub(kind, idOrTitle, on){
|
||||
const url = kind==='series'
|
||||
? `/api/series/${on?'subscribe':'unsubscribe'}/${encodeURIComponent(idOrTitle)}/`
|
||||
: `/api/movies/${on?'subscribe':'unsubscribe'}/${encodeURIComponent(idOrTitle)}/`;
|
||||
const resp = await fetch(url, {method:'POST', headers:{'X-CSRFToken': csrf}});
|
||||
if(!resp.ok){ throw new Error('HTTP '+resp.status); }
|
||||
const key = kind==='series' ? `series:${idOrTitle}` : `movie:${idOrTitle}`;
|
||||
if(on) subCache.set(key, true); else subCache.delete(key);
|
||||
}
|
||||
|
||||
let currentEvent = null;
|
||||
let calendar = null;
|
||||
function showEvent(ev){
|
||||
currentEvent = ev;
|
||||
const p = ev.extendedProps || {};
|
||||
const kind = p.kind;
|
||||
const poster = p.poster || 'https://via.placeholder.com/130x195?text=No+Poster';
|
||||
mPoster.src = poster;
|
||||
if(kind==='series'){
|
||||
mTitle.textContent = `${p.seriesTitle} — S${p.seasonNumber}E${p.episodeNumber}${p.episodeTitle?(' · '+p.episodeTitle):''}`;
|
||||
mOverview.textContent = p.overview || '';
|
||||
epSection.style.display = 'none';
|
||||
mEpisodes.innerHTML = '';
|
||||
mSub.textContent = new Date(ev.start).toLocaleString();
|
||||
} else {
|
||||
mTitle.textContent = p.title || ev.title;
|
||||
mOverview.textContent = p.overview || '';
|
||||
epSection.style.display = 'none';
|
||||
mEpisodes.innerHTML = '';
|
||||
mSub.textContent = new Date(ev.start).toLocaleDateString();
|
||||
}
|
||||
subscribeBtn.textContent = isSub(kind, kind==='series'?p.seriesId:(p.title||'')) ? 'Unsubscribe' : 'Subscribe';
|
||||
openModal();
|
||||
}
|
||||
subscribeBtn.addEventListener('click', async ()=>{
|
||||
if(!currentEvent) return;
|
||||
const p = currentEvent.extendedProps || {};
|
||||
const kind = p.kind;
|
||||
const key = kind==='series'? p.seriesId : (p.title||'');
|
||||
const newState = !isSub(kind, key);
|
||||
// Optimistic UI
|
||||
subscribeBtn.textContent = newState ? 'Unsubscribe' : 'Subscribe';
|
||||
// Update event visuals immediately
|
||||
try {
|
||||
if(currentEvent){
|
||||
currentEvent.setExtendedProp('subscribed', newState);
|
||||
// Also tag by kind for subtle color; ensure classNames contains baseline kind
|
||||
const base = [];
|
||||
if((currentEvent.extendedProps||{}).kind === 'series') base.push('event-series');
|
||||
if((currentEvent.extendedProps||{}).kind === 'movie') base.push('event-movie');
|
||||
if(newState) base.push('subscribed-event');
|
||||
currentEvent.setProp('classNames', base);
|
||||
if(calendar) calendar.rerenderEvents();
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
try {
|
||||
await toggleSub(kind, key, newState);
|
||||
if(bc) bc.postMessage({ type:'sub_change', kind, key, on:newState });
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
// revert visual/state on failure
|
||||
try {
|
||||
if(currentEvent){
|
||||
currentEvent.setExtendedProp('subscribed', !newState);
|
||||
const base = [];
|
||||
if((currentEvent.extendedProps||{}).kind === 'series') base.push('event-series');
|
||||
if((currentEvent.extendedProps||{}).kind === 'movie') base.push('event-movie');
|
||||
if(!newState) base.push('subscribed-event');
|
||||
currentEvent.setProp('classNames', base);
|
||||
if(calendar) calendar.rerenderEvents();
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
await loadAllSubs();
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
const isCompact = () => (calendarEl?.clientWidth || window.innerWidth) < 720;
|
||||
const compact = isCompact();
|
||||
calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: compact ? 'listWeek' : 'dayGridMonth',
|
||||
height: 'auto',
|
||||
locale: 'en',
|
||||
headerToolbar: compact
|
||||
? { left:'prev,next', center:'title', right:'listWeek,dayGridMonth' }
|
||||
: { left:'prev,next today', center:'title', right:'dayGridMonth,timeGridWeek,listWeek' },
|
||||
buttonText: { listWeek: 'List', dayGridMonth: 'Month', timeGridWeek: 'Week', today: 'Today' },
|
||||
firstDay: 1,
|
||||
nowIndicator: true,
|
||||
dayMaxEventRows: compact ? 2 : 5,
|
||||
expandRows: true,
|
||||
handleWindowResize: true,
|
||||
windowResize: function(){
|
||||
const c = isCompact();
|
||||
const should = c ? 'listWeek' : 'dayGridMonth';
|
||||
if(calendar.view.type !== should){ calendar.changeView(should); }
|
||||
calendar.setOption('headerToolbar', c
|
||||
? { left:'prev,next', center:'title', right:'listWeek,dayGridMonth' }
|
||||
: { left:'prev,next today', center:'title', right:'dayGridMonth,timeGridWeek,listWeek' }
|
||||
);
|
||||
calendar.setOption('dayMaxEventRows', c ? 2 : 5);
|
||||
calendar.setOption('displayEventTime', !c);
|
||||
},
|
||||
eventClassNames: function(arg){
|
||||
const p = arg.event.extendedProps || {};
|
||||
const classes = [];
|
||||
if(p.kind === 'series') classes.push('event-series');
|
||||
if(p.kind === 'movie') classes.push('event-movie');
|
||||
if(p.subscribed) classes.push('subscribed-event');
|
||||
return classes;
|
||||
},
|
||||
events: async (info, success, failure) => {
|
||||
try {
|
||||
const url = `/api/calendar/events/?days={{ days|default:60 }}`;
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
const evs = (data.events||[]);
|
||||
success(evs);
|
||||
} catch(err){ failure(err); }
|
||||
},
|
||||
eventClick: function(arg){ arg.jsEvent.preventDefault(); showEvent(arg.event); },
|
||||
eventDidMount: function(info){
|
||||
const p = info.event.extendedProps || {};
|
||||
if(info.view.type.startsWith('list') && p.poster){
|
||||
const img = document.createElement('img');
|
||||
img.src = p.poster; img.className = 'event-poster';
|
||||
const titleEl = info.el.querySelector('.fc-list-event-title');
|
||||
if(titleEl){ titleEl.prepend(img); }
|
||||
}
|
||||
}
|
||||
});
|
||||
calendar.render();
|
||||
// Listen for sub changes from other tabs/pages
|
||||
if(bc){
|
||||
bc.onmessage = (evt)=>{
|
||||
const msg = evt.data || {};
|
||||
if(msg.type !== 'sub_change') return;
|
||||
const {kind, key, on} = msg;
|
||||
try {
|
||||
calendar.getEvents().forEach(ev=>{
|
||||
const p = ev.extendedProps || {};
|
||||
const match = (kind==='series' && String(p.seriesId)===String(key)) || (kind==='movie' && (p.title||'')===key);
|
||||
if(match){
|
||||
ev.setExtendedProp('subscribed', on);
|
||||
const base = [];
|
||||
if(p.kind==='series') base.push('event-series');
|
||||
if(p.kind==='movie') base.push('event-movie');
|
||||
if(on) base.push('subscribed-event');
|
||||
ev.setProp('classNames', base);
|
||||
}
|
||||
});
|
||||
calendar.rerenderEvents();
|
||||
} catch(e) { /* ignore */ }
|
||||
};
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Subscribarr – Übersicht{% endblock %}
|
||||
{% block title %}Subscribarr – Overview{% endblock %}
|
||||
|
||||
{% block extra_style %}
|
||||
<link rel="stylesheet" href="{% static 'css/index.css' %}">
|
||||
@@ -20,26 +20,30 @@
|
||||
<div class="controls">
|
||||
<form method="get" class="controls-form">
|
||||
<input type="hidden" name="kind" value="{{ kind|default:'all' }}">
|
||||
<input type="text" name="q" placeholder="Suche Serien/Filme…" value="{{ query|default:'' }}">
|
||||
<input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Zeitraum in Tagen">
|
||||
<button type="submit">Suchen</button>
|
||||
<input type="text" name="q" placeholder="Search series/movies…" value="{{ query|default:'' }}">
|
||||
<input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Time range (days)">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<nav class="seg" aria-label="Typ filtern">
|
||||
<nav class="seg" aria-label="Filter type">
|
||||
{% with qs=query|urlencode %}
|
||||
<a href="?kind=all&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
|
||||
class="{% if kind == 'all' %}active{% endif %}">Alle</a>
|
||||
class="{% if kind == 'all' %}active{% endif %}">All</a>
|
||||
<a href="?kind=series&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
|
||||
class="{% if kind == 'series' %}active{% endif %}">Serien</a>
|
||||
class="{% if kind == 'series' %}active{% endif %}">Series</a>
|
||||
<a href="?kind=movies&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
|
||||
class="{% if kind == 'movies' %}active{% endif %}">Filme</a>
|
||||
class="{% if kind == 'movies' %}active{% endif %}">Movies</a>
|
||||
{% endwith %}
|
||||
</nav>
|
||||
|
||||
<div class="controls-actions">
|
||||
<a href="/calendar/" class="btn btn-accent" title="Open calendar">📅 Calendar</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_series %}
|
||||
<div class="section">
|
||||
<h2>Laufende Serien</h2>
|
||||
<h2>Ongoing series</h2>
|
||||
<div class="grid">
|
||||
{% for s in series_grouped %}
|
||||
<div class="card" data-series-id="{{ s.seriesId }}" data-title="{{ s.seriesTitle|escape }}"
|
||||
@@ -60,7 +64,7 @@
|
||||
<span class="muted" data-dt="{{ e.airDateUtc|default:'' }}"></span>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="muted">Keine kommenden Episoden.</div>
|
||||
<div class="muted">No upcoming episodes.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +76,7 @@
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="muted">Keine Serien gefunden.</p>
|
||||
<p class="muted">No series found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +84,7 @@
|
||||
|
||||
{% if show_movies %}
|
||||
<div class="section">
|
||||
<h2>Anstehende Filme</h2>
|
||||
<h2>Upcoming movies</h2>
|
||||
<div class="grid">
|
||||
{% for m in movies %}
|
||||
<div class="movie-card" data-kind="movie" data-title="{{ m.title|escape }}"
|
||||
@@ -92,13 +96,13 @@
|
||||
{% endif %}
|
||||
<div class="title">{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}</div>
|
||||
<div class="muted">
|
||||
{% if m.inCinemas %}Kino: <span data-dt="{{ m.inCinemas }}"></span>{% endif %}
|
||||
{% if m.inCinemas %}In theaters: <span data-dt="{{ m.inCinemas }}"></span>{% endif %}
|
||||
{% if m.digitalRelease %}<br>Digital: <span data-dt="{{ m.digitalRelease }}"></span>{% endif %}
|
||||
{% if m.physicalRelease %}<br>Disc: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %}
|
||||
{% if m.physicalRelease %}<br>Physical: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="muted">Keine Filme gefunden.</p>
|
||||
<p class="muted">No movies found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,17 +122,17 @@
|
||||
<div id="mSub" class="m-sub"></div>
|
||||
<div id="mBadges" class="badges"></div>
|
||||
</div>
|
||||
<button class="modal-close" title="Schließen" aria-label="Schließen">×</button>
|
||||
<button class="modal-close" title="Close" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="section-block">
|
||||
<div class="section-title">Beschreibung</div>
|
||||
<div class="section-title">Overview</div>
|
||||
<div id="mOverview" class="desc muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-block">
|
||||
<div class="section-title">Kommende Episoden</div>
|
||||
<div class="section-title">Upcoming episodes</div>
|
||||
<div class="section-divider"></div>
|
||||
<div id="mEpisodes"></div>
|
||||
</div>
|
||||
@@ -153,6 +157,7 @@
|
||||
const mSub = $("#mSub");
|
||||
const epSection = mEpisodes.closest(".section-block");
|
||||
const subscribeBtn = $("#subscribeBtn");
|
||||
const bc = ('BroadcastChannel' in window) ? new BroadcastChannel('subscribarr-sub') : null;
|
||||
|
||||
let lastClickedCard = null;
|
||||
|
||||
@@ -178,7 +183,7 @@
|
||||
return "movie:" + (card.dataset.title || "");
|
||||
}
|
||||
|
||||
// Cache für Abonnement-Status
|
||||
// Cache for subscription state
|
||||
const subCache = new Map();
|
||||
|
||||
async function loadAllSubs() {
|
||||
@@ -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;
|
||||
|
@@ -2,13 +2,17 @@ from django.urls import path
|
||||
from .views import (
|
||||
ArrIndexView, SeriesSubscribeView, SeriesUnsubscribeView,
|
||||
MovieSubscribeView, MovieUnsubscribeView,
|
||||
ListSeriesSubscriptionsView, ListMovieSubscriptionsView
|
||||
ListSeriesSubscriptionsView, ListMovieSubscriptionsView,
|
||||
CalendarView, CalendarEventsApi,
|
||||
)
|
||||
|
||||
app_name = 'arr_api'
|
||||
|
||||
urlpatterns = [
|
||||
path('', ArrIndexView.as_view(), name='index'),
|
||||
# Calendar
|
||||
path('calendar/', CalendarView.as_view(), name='calendar'),
|
||||
path('api/calendar/events/', CalendarEventsApi.as_view(), name='calendar-events'),
|
||||
|
||||
# Series URLs
|
||||
path('api/series/subscribe/<int:series_id>/', SeriesSubscribeView.as_view(), name='subscribe-series'),
|
||||
|
@@ -13,6 +13,7 @@ from rest_framework import status
|
||||
from settingspanel.models import AppSettings
|
||||
from .services import sonarr_calendar, radarr_calendar, ArrServiceError
|
||||
from .models import SeriesSubscription, MovieSubscription
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def _get_int(request, key, default):
|
||||
@@ -67,13 +68,13 @@ class ArrIndexView(View):
|
||||
try:
|
||||
eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
|
||||
except ArrServiceError as e:
|
||||
messages.error(request, f"Sonarr nicht erreichbar: {e}")
|
||||
messages.error(request, f"Sonarr is not reachable: {e}")
|
||||
|
||||
# Radarr robust laden
|
||||
try:
|
||||
movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
|
||||
except ArrServiceError as e:
|
||||
messages.error(request, f"Radarr nicht erreichbar: {e}")
|
||||
messages.error(request, f"Radarr is not reachable: {e}")
|
||||
|
||||
# Suche
|
||||
if q:
|
||||
@@ -128,6 +129,74 @@ class ArrIndexView(View):
|
||||
})
|
||||
|
||||
|
||||
class CalendarView(View):
|
||||
def get(self, request):
|
||||
days = _get_int(request, "days", 60)
|
||||
return render(request, "arr_api/calendar.html", {"days": days})
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class CalendarEventsApi(APIView):
|
||||
def get(self, request):
|
||||
days = _get_int(request, "days", 60)
|
||||
conf = _arr_conf_from_db()
|
||||
try:
|
||||
eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
|
||||
except ArrServiceError:
|
||||
eps = []
|
||||
try:
|
||||
movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
|
||||
except ArrServiceError:
|
||||
movies = []
|
||||
|
||||
series_sub = set(SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True))
|
||||
movie_sub_titles = set(MovieSubscription.objects.filter(user=request.user).values_list('title', flat=True))
|
||||
|
||||
events = []
|
||||
for e in eps:
|
||||
when = e.get("airDateUtc")
|
||||
if not when:
|
||||
continue
|
||||
events.append({
|
||||
"id": f"s:{e.get('seriesId')}:{e.get('episodeId')}",
|
||||
"title": f"{e.get('seriesTitle','')} — S{e.get('seasonNumber')}E{e.get('episodeNumber')}",
|
||||
"start": when,
|
||||
"allDay": False,
|
||||
"extendedProps": {
|
||||
"kind": "series",
|
||||
"seriesId": e.get('seriesId'),
|
||||
"seriesTitle": e.get('seriesTitle'),
|
||||
"seasonNumber": e.get('seasonNumber'),
|
||||
"episodeNumber": e.get('episodeNumber'),
|
||||
"episodeTitle": e.get('title'),
|
||||
"overview": e.get('seriesOverview') or "",
|
||||
"poster": e.get('seriesPoster') or "",
|
||||
"subscribed": int(e.get('seriesId') or 0) in series_sub,
|
||||
}
|
||||
})
|
||||
|
||||
for m in movies:
|
||||
when = m.get('digitalRelease') or m.get('physicalRelease') or m.get('inCinemas')
|
||||
if not when:
|
||||
continue
|
||||
events.append({
|
||||
"id": f"m:{m.get('movieId') or m.get('title')}",
|
||||
"title": m.get('title') or "(movie)",
|
||||
"start": when,
|
||||
"allDay": True,
|
||||
"extendedProps": {
|
||||
"kind": "movie",
|
||||
"movieId": m.get('movieId'),
|
||||
"title": m.get('title'),
|
||||
"overview": m.get('overview') or "",
|
||||
"poster": m.get('posterUrl') or "",
|
||||
"subscribed": (m.get('title') or '') in movie_sub_titles,
|
||||
}
|
||||
})
|
||||
|
||||
return Response({"events": events})
|
||||
|
||||
|
||||
class SubscribeSeriesView(View):
|
||||
@method_decorator(require_POST)
|
||||
def post(self, request, series_id):
|
||||
@@ -145,9 +214,9 @@ class SubscribeSeriesView(View):
|
||||
)
|
||||
|
||||
if created:
|
||||
messages.success(request, f'Serie "{series_data["series_title"]}" wurde abonniert!')
|
||||
messages.success(request, f'Subscribed to series "{series_data["series_title"]}"!')
|
||||
else:
|
||||
messages.info(request, f'Serie "{series_data["series_title"]}" war bereits abonniert.')
|
||||
messages.info(request, f'Series "{series_data["series_title"]}" was already subscribed.')
|
||||
|
||||
return redirect('arr_api:index')
|
||||
|
||||
@@ -157,7 +226,7 @@ class UnsubscribeSeriesView(View):
|
||||
subscription = get_object_or_404(SeriesSubscription, series_id=series_id)
|
||||
series_title = subscription.series_title
|
||||
subscription.delete()
|
||||
messages.success(request, f'Abonnement für "{series_title}" wurde beendet.')
|
||||
messages.success(request, f'Subscription for "{series_title}" has been removed.')
|
||||
return redirect('arr_api:index')
|
||||
|
||||
class SubscribeMovieView(View):
|
||||
@@ -178,9 +247,9 @@ class SubscribeMovieView(View):
|
||||
)
|
||||
|
||||
if created:
|
||||
messages.success(request, f'Film "{movie_data["title"]}" wurde abonniert!')
|
||||
messages.success(request, f'Subscribed to movie "{movie_data["title"]}"!')
|
||||
else:
|
||||
messages.info(request, f'Film "{movie_data["title"]}" war bereits abonniert.')
|
||||
messages.info(request, f'Movie "{movie_data["title"]}" was already subscribed.')
|
||||
|
||||
return redirect('arr_api:index')
|
||||
|
||||
@@ -190,14 +259,14 @@ class UnsubscribeMovieView(View):
|
||||
subscription = get_object_or_404(MovieSubscription, movie_id=movie_id)
|
||||
movie_title = subscription.title
|
||||
subscription.delete()
|
||||
messages.success(request, f'Abonnement für "{movie_title}" wurde beendet.')
|
||||
messages.success(request, f'Subscription for "{movie_title}" has been removed.')
|
||||
return redirect('arr_api:index')
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def subscribe_series(request, series_id):
|
||||
"""Serie abonnieren"""
|
||||
"""Subscribe to a series"""
|
||||
try:
|
||||
# Existiert bereits?
|
||||
if SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists():
|
||||
@@ -224,7 +293,7 @@ def subscribe_series(request, series_id):
|
||||
@require_POST
|
||||
@login_required
|
||||
def unsubscribe_series(request, series_id):
|
||||
"""Serie deabonnieren"""
|
||||
"""Unsubscribe from a series"""
|
||||
try:
|
||||
SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete()
|
||||
return JsonResponse({'success': True})
|
||||
@@ -233,14 +302,14 @@ def unsubscribe_series(request, series_id):
|
||||
|
||||
@login_required
|
||||
def is_subscribed_series(request, series_id):
|
||||
"""Prüfe ob Serie abonniert ist"""
|
||||
"""Check if a series is subscribed"""
|
||||
is_subbed = SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists()
|
||||
return JsonResponse({'subscribed': is_subbed})
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def subscribe_movie(request, movie_id):
|
||||
"""Film abonnieren"""
|
||||
"""Subscribe to a movie"""
|
||||
try:
|
||||
# Existiert bereits?
|
||||
if MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists():
|
||||
@@ -268,7 +337,7 @@ def subscribe_movie(request, movie_id):
|
||||
@require_POST
|
||||
@login_required
|
||||
def unsubscribe_movie(request, movie_id):
|
||||
"""Film deabonnieren"""
|
||||
"""Unsubscribe from a movie"""
|
||||
try:
|
||||
MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).delete()
|
||||
return JsonResponse({'success': True})
|
||||
@@ -277,13 +346,13 @@ def unsubscribe_movie(request, movie_id):
|
||||
|
||||
@login_required
|
||||
def is_subscribed_movie(request, movie_id):
|
||||
"""Prüfe ob Film abonniert ist"""
|
||||
"""Check if a movie is subscribed"""
|
||||
is_subbed = MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists()
|
||||
return JsonResponse({'subscribed': is_subbed})
|
||||
|
||||
@login_required
|
||||
def get_subscriptions(request):
|
||||
"""Hole alle Abonnements des Users"""
|
||||
"""Get all subscriptions for the user"""
|
||||
series = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True)
|
||||
movies = MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True)
|
||||
return JsonResponse({
|
||||
|
Reference in New Issue
Block a user