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
	 jschaufuss@leitwerk.de
					jschaufuss@leitwerk.de