base
This commit is contained in:
		
							
								
								
									
										0
									
								
								arr_api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								arr_api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										3
									
								
								arr_api/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								arr_api/admin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.contrib import admin | ||||
|  | ||||
| # Register your models here. | ||||
							
								
								
									
										6
									
								
								arr_api/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								arr_api/apps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class ArrApiConfig(AppConfig): | ||||
|     default_auto_field = 'django.db.models.BigAutoField' | ||||
|     name = 'arr_api' | ||||
							
								
								
									
										0
									
								
								arr_api/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								arr_api/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										3
									
								
								arr_api/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								arr_api/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.db import models | ||||
|  | ||||
| # Create your models here. | ||||
							
								
								
									
										133
									
								
								arr_api/services.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								arr_api/services.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| # arr_api/services.py | ||||
| import os | ||||
| import requests | ||||
| from datetime import datetime, timedelta, timezone | ||||
| from dateutil.parser import isoparse | ||||
|  | ||||
| # ENV-Fallbacks | ||||
| ENV_SONARR_URL = os.getenv("SONARR_URL", "") | ||||
| ENV_SONARR_KEY = os.getenv("SONARR_API_KEY", "") | ||||
| ENV_RADARR_URL = os.getenv("RADARR_URL", "") | ||||
| ENV_RADARR_KEY = os.getenv("RADARR_API_KEY", "") | ||||
| DEFAULT_DAYS = int(os.getenv("ARR_DEFAULT_DAYS", "30")) | ||||
|  | ||||
| class ArrServiceError(Exception): | ||||
|     pass | ||||
|  | ||||
| def _get(url, headers, params=None, timeout=5): | ||||
|     try: | ||||
|         r = requests.get(url, headers=headers, params=params or {}, timeout=timeout) | ||||
|         r.raise_for_status() | ||||
|         return r.json() | ||||
|     except requests.exceptions.RequestException as e: | ||||
|         raise ArrServiceError(str(e)) | ||||
|  | ||||
| def _abs_url(base: str, p: str | None) -> str | None: | ||||
|     if not p: | ||||
|         return None | ||||
|     return f"{base.rstrip('/')}{p}" if p.startswith("/") else p | ||||
|  | ||||
| def sonarr_calendar(days: int | None = None, base_url: str | None = None, api_key: str | None = None): | ||||
|     base = (base_url or ENV_SONARR_URL).strip() | ||||
|     key  = (api_key  or ENV_SONARR_KEY).strip() | ||||
|     if not base or not key: | ||||
|         return [] | ||||
|     d = days or DEFAULT_DAYS | ||||
|     start = datetime.now(timezone.utc) | ||||
|     end = start + timedelta(days=d) | ||||
|  | ||||
|     url = f"{base.rstrip('/')}/api/v3/calendar" | ||||
|     headers = {"X-Api-Key": key} | ||||
|     data = _get(url, headers, params={ | ||||
|         "start": start.date().isoformat(), | ||||
|         "end": end.date().isoformat(), | ||||
|         "unmonitored": "false", | ||||
|         "includeSeries": "true", | ||||
|     }) | ||||
|  | ||||
|     out = [] | ||||
|     for ep in data: | ||||
|         series = ep.get("series") or {} | ||||
|         # Poster finden | ||||
|         poster = None | ||||
|         for img in (series.get("images") or []): | ||||
|             if (img.get("coverType") or "").lower() == "poster": | ||||
|                 poster = img.get("remoteUrl") or _abs_url(base, img.get("url")) | ||||
|                 if poster: | ||||
|                     break | ||||
|  | ||||
|         aired = isoparse(ep["airDateUtc"]).isoformat() if ep.get("airDateUtc") else None | ||||
|         out.append({ | ||||
|             "seriesId": series.get("id"), | ||||
|             "seriesTitle": series.get("title"), | ||||
|             "seriesStatus": (series.get("status") or "").lower(), | ||||
|             "seriesPoster": poster, | ||||
|             "seriesOverview": series.get("overview") or "", | ||||
|             "seriesGenres": series.get("genres") or [], | ||||
|             "episodeId": ep.get("id"), | ||||
|             "seasonNumber": ep.get("seasonNumber"), | ||||
|             "episodeNumber": ep.get("episodeNumber"), | ||||
|             "title": ep.get("title"), | ||||
|             "airDateUtc": aired, | ||||
|             "tvdbId": series.get("tvdbId"), | ||||
|             "imdbId": series.get("imdbId"), | ||||
|             "network": series.get("network"), | ||||
|         }) | ||||
|     return [x for x in out if x["seriesStatus"] == "continuing"] | ||||
|  | ||||
| def radarr_calendar(days: int | None = None, base_url: str | None = None, api_key: str | None = None): | ||||
|     base = (base_url or ENV_RADARR_URL).strip() | ||||
|     key  = (api_key  or ENV_RADARR_KEY).strip() | ||||
|     if not base or not key: | ||||
|         return [] | ||||
|     d = days or DEFAULT_DAYS | ||||
|     start = datetime.now(timezone.utc) | ||||
|     end = start + timedelta(days=d) | ||||
|  | ||||
|     url = f"{base.rstrip('/')}/api/v3/calendar" | ||||
|     headers = {"X-Api-Key": key} | ||||
|     data = _get(url, headers, params={ | ||||
|         "start": start.date().isoformat(), | ||||
|         "end": end.date().isoformat(), | ||||
|         "unmonitored": "false", | ||||
|         "includeMovie": "true", | ||||
|     }) | ||||
|  | ||||
|     out = [] | ||||
|     for it in data: | ||||
|         movie = it.get("movie") or it | ||||
|         # Poster finden | ||||
|         poster = None | ||||
|         for img in (movie.get("images") or []): | ||||
|             if (img.get("coverType") or "").lower() == "poster": | ||||
|                 poster = img.get("remoteUrl") or _abs_url(base, img.get("url")) | ||||
|                 if poster: | ||||
|                     break | ||||
|  | ||||
|         out.append({ | ||||
|             "movieId": movie.get("id"), | ||||
|             "title": movie.get("title"), | ||||
|             "year": movie.get("year"), | ||||
|             "tmdbId": movie.get("tmdbId"), | ||||
|             "imdbId": movie.get("imdbId"), | ||||
|             "posterUrl": poster, | ||||
|             "overview": movie.get("overview") or "", | ||||
|             "inCinemas": movie.get("inCinemas"), | ||||
|             "physicalRelease": movie.get("physicalRelease"), | ||||
|             "digitalRelease": movie.get("digitalRelease"), | ||||
|             "hasFile": movie.get("hasFile"), | ||||
|             "isAvailable": movie.get("isAvailable"), | ||||
|         }) | ||||
|  | ||||
|     def is_upcoming(m): | ||||
|         for k in ("inCinemas", "physicalRelease", "digitalRelease"): | ||||
|             v = m.get(k) | ||||
|             if v: | ||||
|                 try: | ||||
|                     if isoparse(v) > datetime.now(timezone.utc): | ||||
|                         return True | ||||
|                 except Exception: | ||||
|                     pass | ||||
|         return False | ||||
|  | ||||
|     return [m for m in out if is_upcoming(m)] | ||||
							
								
								
									
										738
									
								
								arr_api/templates/arr_api/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										738
									
								
								arr_api/templates/arr_api/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,738 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="de"> | ||||
|  | ||||
| <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||
|     <title>Subscribarr – Übersicht</title> | ||||
|     <style> | ||||
|         :root { | ||||
|             --bg: #0b0b10; | ||||
|             --panel: #12121a; | ||||
|             --panel-b: #1f2030; | ||||
|             --accent: #3b82f6; | ||||
|             --muted: #9aa0b4; | ||||
|             --text: #e6e6e6; | ||||
|         } | ||||
|  | ||||
|         * { | ||||
|             box-sizing: border-box; | ||||
|         } | ||||
|  | ||||
|         body { | ||||
|             font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial, sans-serif; | ||||
|             background: var(--bg); | ||||
|             color: var(--text); | ||||
|             margin: 0; | ||||
|         } | ||||
|  | ||||
|         .wrap { | ||||
|             max-width: 1200px; | ||||
|             margin: 0 auto; | ||||
|             padding: 16px; | ||||
|         } | ||||
|  | ||||
|         h1 { | ||||
|             margin: 4px 0 12px; | ||||
|             font-size: clamp(1.2rem, 2.5vw, 1.6rem); | ||||
|         } | ||||
|  | ||||
|         /* Controls */ | ||||
|         .controls { | ||||
|             display: flex; | ||||
|             flex-wrap: wrap; | ||||
|             align-items: center; | ||||
|             gap: 8px; | ||||
|             margin-bottom: 16px; | ||||
|         } | ||||
|  | ||||
|         .controls form { | ||||
|             display: flex; | ||||
|             align-items: center; | ||||
|             gap: 8px; | ||||
|             flex: 1; | ||||
|             min-width: 220px; | ||||
|         } | ||||
|  | ||||
|         .controls input[type=text] { | ||||
|             flex: 1; | ||||
|             padding: 10px 12px; | ||||
|             border-radius: 10px; | ||||
|             border: 1px solid #2a2a34; | ||||
|             background: #111119; | ||||
|             color: var(--text); | ||||
|             font-size: 1rem; | ||||
|             min-width: 0; | ||||
|         } | ||||
|  | ||||
|         .controls button[type=submit] { | ||||
|             padding: 10px 14px; | ||||
|             border-radius: 10px; | ||||
|             border: 0; | ||||
|             background: var(--accent); | ||||
|             color: #fff; | ||||
|             cursor: pointer; | ||||
|         } | ||||
|  | ||||
|         .seg { | ||||
|             display: inline-flex; | ||||
|             background: #0f0f17; | ||||
|             border: 1px solid #28293a; | ||||
|             border-radius: 10px; | ||||
|             overflow: hidden; | ||||
|         } | ||||
|  | ||||
|         .seg a { | ||||
|             padding: 8px 12px; | ||||
|             color: var(--text); | ||||
|             text-decoration: none; | ||||
|         } | ||||
|  | ||||
|         .seg a.active { | ||||
|             background: var(--accent); | ||||
|             color: #fff; | ||||
|         } | ||||
|  | ||||
|         /* Cards */ | ||||
|         .grid { | ||||
|             display: grid; | ||||
|             grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); | ||||
|             gap: 14px; | ||||
|         } | ||||
|  | ||||
|         @media (min-width: 900px) { | ||||
|             .grid { | ||||
|                 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         .card { | ||||
|             background: var(--panel); | ||||
|             border: 1px solid var(--panel-b); | ||||
|             border-radius: 12px; | ||||
|             overflow: hidden; | ||||
|             display: flex; | ||||
|             gap: 12px; | ||||
|             padding: 12px; | ||||
|             transition: transform .08s ease, border-color .08s; | ||||
|             cursor: pointer; | ||||
|         } | ||||
|  | ||||
|         .card:active, | ||||
|         .card:hover { | ||||
|             transform: translateY(-2px); | ||||
|             border-color: #2a2b44; | ||||
|         } | ||||
|  | ||||
|         .poster { | ||||
|             width: 110px; | ||||
|             height: 165px; | ||||
|             background: #222233; | ||||
|             border-radius: 8px; | ||||
|             overflow: hidden; | ||||
|             flex: 0 0 auto; | ||||
|         } | ||||
|  | ||||
|         .poster img { | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|             object-fit: cover; | ||||
|             display: block; | ||||
|         } | ||||
|  | ||||
|         .meta { | ||||
|             flex: 1 1 auto; | ||||
|             min-width: 0; | ||||
|         } | ||||
|  | ||||
|         .title { | ||||
|             font-weight: 600; | ||||
|             font-size: 1rem; | ||||
|             margin-bottom: 6px; | ||||
|             white-space: nowrap; | ||||
|             overflow: hidden; | ||||
|             text-overflow: ellipsis; | ||||
|         } | ||||
|  | ||||
|         .episodes { | ||||
|             max-height: 210px; | ||||
|             overflow: auto; | ||||
|             padding-right: 6px; | ||||
|         } | ||||
|  | ||||
|         .ep { | ||||
|             font-size: 0.92rem; | ||||
|             padding: 6px 0; | ||||
|             border-bottom: 1px dashed #25263a; | ||||
|         } | ||||
|  | ||||
|         .muted { | ||||
|             color: var(--muted); | ||||
|             font-size: 0.9rem; | ||||
|         } | ||||
|  | ||||
|         .movie-card { | ||||
|             background: var(--panel); | ||||
|             border: 1px solid var(--panel-b); | ||||
|             border-radius: 12px; | ||||
|             padding: 12px; | ||||
|         } | ||||
|  | ||||
|         .movie-card img { | ||||
|             width: 100%; | ||||
|             border-radius: 8px; | ||||
|             margin-bottom: 8px; | ||||
|             display: block; | ||||
|             height: auto; | ||||
|         } | ||||
|  | ||||
|         .section { | ||||
|             margin-top: 22px; | ||||
|         } | ||||
|  | ||||
|         .section h2 { | ||||
|             font-size: 1.1rem; | ||||
|             margin: 12px 0; | ||||
|         } | ||||
|  | ||||
|         /* Modal */ | ||||
|         .modal-backdrop { | ||||
|             position: fixed; | ||||
|             inset: 0; | ||||
|             background: rgba(10, 12, 20, .55); | ||||
|             backdrop-filter: blur(4px); | ||||
|             display: none; | ||||
|             align-items: center; | ||||
|             justify-content: center; | ||||
|             z-index: 1000; | ||||
|             padding: 12px; | ||||
|         } | ||||
|  | ||||
|         .modal { | ||||
|             width: min(960px, 100%); | ||||
|             max-height: 92vh; | ||||
|             overflow: auto; | ||||
|             background: linear-gradient(180deg, #10121b 0%, #0d0f16 100%); | ||||
|             border: 1px solid #2a2b44; | ||||
|             border-radius: 16px; | ||||
|             box-shadow: 0 24px 80px rgba(0, 0, 0, .6); | ||||
|         } | ||||
|  | ||||
|         .modal-header { | ||||
|             position: sticky; | ||||
|             grid-template-columns: auto 1fr auto; | ||||
|             top: 0; | ||||
|             z-index: 2; | ||||
|             display: grid; | ||||
|             grid-template-columns: 130px 1fr auto; | ||||
|             gap: 14px; | ||||
|             align-items: center; | ||||
|             padding: 16px; | ||||
|             background: rgba(13, 15, 22, .85); | ||||
|             backdrop-filter: blur(4px); | ||||
|             border-bottom: 1px solid #20223a; | ||||
|         } | ||||
|  | ||||
|         .m-poster { | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|             align-items: center; | ||||
|             gap: 10px; | ||||
|             width: 130px; | ||||
|             height: 195px; | ||||
|             border-radius: 10px; | ||||
|             overflow: hidden; | ||||
|             background: #222233; | ||||
|         } | ||||
|  | ||||
|         .m-poster img { | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|             object-fit: cover; | ||||
|             display: block; | ||||
|         } | ||||
|  | ||||
|         .m-title { | ||||
|             font-size: 1.25rem; | ||||
|             font-weight: 750; | ||||
|             line-height: 1.2; | ||||
|             margin-bottom: 6px; | ||||
|         } | ||||
|  | ||||
|         .m-sub { | ||||
|             color: var(--muted); | ||||
|             font-size: .92rem; | ||||
|         } | ||||
|  | ||||
|         .badges { | ||||
|             display: flex; | ||||
|             flex-wrap: wrap; | ||||
|             gap: 6px; | ||||
|             margin-top: 8px; | ||||
|         } | ||||
|  | ||||
|         .badge { | ||||
|             padding: 4px 8px; | ||||
|             border-radius: 999px; | ||||
|             background: #171a26; | ||||
|             border: 1px solid #2a2b44; | ||||
|             font-size: .82rem; | ||||
|             color: #cfd3ea; | ||||
|         } | ||||
|  | ||||
|         .modal-close { | ||||
|             margin-left: auto; | ||||
|             align-self: start; | ||||
|             background: #1a1f33; | ||||
|             border: 1px solid #2a2b44; | ||||
|             color: #c9cbe3; | ||||
|             width: 36px; | ||||
|             height: 36px; | ||||
|             justify-self: end; | ||||
|             border-radius: 10px; | ||||
|             cursor: pointer; | ||||
|             display: grid; | ||||
|             place-items: center; | ||||
|             font-size: 1.4rem; | ||||
|             line-height: 1; | ||||
|             transition: transform .08s ease, background .12s ease, border-color .12s ease; | ||||
|         } | ||||
|  | ||||
|         .modal-close:hover { | ||||
|             background: #243055; | ||||
|             border-color: #3b4aa0; | ||||
|             transform: translateY(-1px); | ||||
|         } | ||||
|  | ||||
|         .modal-body { | ||||
|             padding: 16px; | ||||
|             display: grid; | ||||
|             grid-template-columns: 1fr; | ||||
|             gap: 16px; | ||||
|         } | ||||
|  | ||||
|         @media (min-width: 900px) { | ||||
|             .modal-body { | ||||
|                 grid-template-columns: 1.2fr .8fr; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         .section-block { | ||||
|             background: #101327; | ||||
|             border: 1px solid #20223a; | ||||
|             border-radius: 12px; | ||||
|             padding: 14px; | ||||
|         } | ||||
|  | ||||
|         .section-title { | ||||
|             font-size: 1rem; | ||||
|             font-weight: 650; | ||||
|             margin: 0 0 8px; | ||||
|         } | ||||
|  | ||||
|         .section-divider { | ||||
|             height: 1px; | ||||
|             background: #20223a; | ||||
|             margin: 10px 0; | ||||
|             opacity: .9; | ||||
|         } | ||||
|  | ||||
|         .ep-row { | ||||
|             border-bottom: 1px dashed #262947; | ||||
|             padding: 8px 0; | ||||
|             font-size: .94rem; | ||||
|         } | ||||
|  | ||||
|         .ep-row:last-child { | ||||
|             border-bottom: 0; | ||||
|         } | ||||
|  | ||||
|         /* control */ | ||||
|         .controls input[type=number] { | ||||
|             width: 90px; | ||||
|             padding: 10px 12px; | ||||
|             border-radius: 10px; | ||||
|             border: 1px solid #2a2a34; | ||||
|             background: #111119; | ||||
|             color: var(--text); | ||||
|             font-size: 1rem; | ||||
|             -moz-appearance: textfield; | ||||
|         } | ||||
|  | ||||
|         .btn-subscribe { | ||||
|             padding: 8px 14px; | ||||
|             border-radius: 10px; | ||||
|             background: #1f6f3a; | ||||
|             border: 1px solid #2a2b34; | ||||
|             color: #fff; | ||||
|             cursor: pointer; | ||||
|             font-weight: 600; | ||||
|             transition: background .15s ease, transform .08s ease; | ||||
|         } | ||||
|  | ||||
|         .btn-subscribe:hover { | ||||
|             background: #2b8f4d; | ||||
|         } | ||||
|  | ||||
|         .btn-subscribe:active { | ||||
|             transform: translateY(1px); | ||||
|         } | ||||
|  | ||||
|         .subscribed { | ||||
|             outline: 3px solid #1f6f3a; | ||||
|             /* grüne Markierung am Element */ | ||||
|             outline-offset: 2px; | ||||
|         } | ||||
|  | ||||
|  | ||||
|         .controls input[type=number]::-webkit-outer-spin-button, | ||||
|         .controls input[type=number]::-webkit-inner-spin-button { | ||||
|             -webkit-appearance: none; | ||||
|             margin: 0; | ||||
|         } | ||||
|  | ||||
|         .controls input[type=number]:focus { | ||||
|             border-color: var(--accent); | ||||
|             outline: none; | ||||
|         } | ||||
|  | ||||
|         .movie-card { | ||||
|             background: var(--panel); | ||||
|             border: 1px solid var(--panel-b); | ||||
|             border-radius: 12px; | ||||
|             padding: 12px; | ||||
|             cursor: pointer; | ||||
|             /* klickbar */ | ||||
|             transition: transform .08s ease, border-color .08s; | ||||
|             /* wie .card */ | ||||
|         } | ||||
|  | ||||
|         .movie-card:hover, | ||||
|         .movie-card:focus-within { | ||||
|             transform: translateY(-2px); | ||||
|             border-color: #2a2b44; | ||||
|         } | ||||
|  | ||||
|         .movie-card:active { | ||||
|             transform: translateY(0); | ||||
|             /* kleiner Tap-Feedback */ | ||||
|         } | ||||
|     </style> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|  | ||||
|     <div class="topbar" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"> | ||||
|         <div></div> | ||||
|         <div style="display:flex;gap:8px;align-items:center"> | ||||
|             <div class="debug" title="Debug" | ||||
|                 style="padding:8px 10px;border:1px solid #2a2a34;border-radius:10px;background:#111119;color:#cfd3ea;font-size:.9rem;"> | ||||
|                 kind={{ kind }} · days={{ days }} · series={{ series_grouped|length }} · movies={{ movies|length }} | ||||
|             </div> | ||||
|             <a href="/settings/" class="btn" | ||||
|                 style="padding:8px 12px;border-radius:10px;border:1px solid #2a2a34;background:#111119;color:#fff;text-decoration:none"> | ||||
|                 ⚙️ Einstellungen | ||||
|             </a> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|  | ||||
|     <div class="wrap"> | ||||
|         <h1>Subscribarr</h1> | ||||
|  | ||||
|         <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> | ||||
|             </form> | ||||
|  | ||||
|             <nav class="seg" aria-label="Typ filtern"> | ||||
|                 {% with qs=query|urlencode %} | ||||
|                 <a href="?kind=all&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}" | ||||
|                     class="{% if kind == 'all' %}active{% endif %}">Alle</a> | ||||
|                 <a href="?kind=series&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}" | ||||
|                     class="{% if kind == 'series' %}active{% endif %}">Serien</a> | ||||
|                 <a href="?kind=movies&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}" | ||||
|                     class="{% if kind == 'movies' %}active{% endif %}">Filme</a> | ||||
|                 {% endwith %} | ||||
|             </nav> | ||||
|         </div> | ||||
|  | ||||
|         {% if show_series %} | ||||
|         <div class="section"> | ||||
|             <h2>Laufende Serien</h2> | ||||
|             <div class="grid"> | ||||
|                 {% for s in series_grouped %} | ||||
|                 <div class="card" data-series-id="{{ s.seriesId }}" data-title="{{ s.seriesTitle|escape }}" | ||||
|                     data-poster="{{ s.seriesPoster|default:'' }}" | ||||
|                     data-overview="{{ s.seriesOverview|default:''|escape }}"> | ||||
|                     <div class="poster"> | ||||
|                         {% if s.seriesPoster %} | ||||
|                         <img src="{{ s.seriesPoster }}" alt="{{ s.seriesTitle }}"> | ||||
|                         {% else %} | ||||
|                         <img src="https://via.placeholder.com/110x165?text=No+Poster" alt=""> | ||||
|                         {% endif %} | ||||
|                     </div> | ||||
|                     <div class="meta"> | ||||
|                         <div class="title" title="{{ s.seriesTitle }}">{{ s.seriesTitle }}</div> | ||||
|                         <div class="episodes"> | ||||
|                             {% for e in s.episodes %} | ||||
|                             <div class="ep"> | ||||
|                                 S{{ e.seasonNumber }}E{{ e.episodeNumber }} — {{ e.title|default:"(tba)" }}<br> | ||||
|                                 <span class="muted" data-dt="{{ e.airDateUtc|default:'' }}"></span> | ||||
|                             </div> | ||||
|                             {% empty %} | ||||
|                             <div class="muted">Keine kommenden Episoden.</div> | ||||
|                             {% endfor %} | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     {# sichere Episoden-JSON für Modal #} | ||||
|                     {% with sid=s.seriesId|stringformat:"s" %} | ||||
|                     {% with eid="eps-"|add:sid %} | ||||
|                     {{ s.episodes|json_script:eid }} | ||||
|                     {% endwith %} | ||||
|                     {% endwith %} | ||||
|                 </div> | ||||
|                 {% empty %} | ||||
|                 <p class="muted">Keine Serien gefunden.</p> | ||||
|                 {% endfor %} | ||||
|             </div> | ||||
|         </div> | ||||
|         {% endif %} | ||||
|  | ||||
|         {% if show_movies %} | ||||
|         <div class="section"> | ||||
|             <h2>Anstehende Filme</h2> | ||||
|             <div class="grid"> | ||||
|                 {% for m in movies %} | ||||
|                 <div class="movie-card" data-kind="movie" data-title="{{ m.title|escape }}" | ||||
|                     data-poster="{{ m.posterUrl|default:'' }}" data-overview="{{ m.overview|default:''|escape }}"> | ||||
|                     {% if m.posterUrl %} | ||||
|                     <img src="{{ m.posterUrl }}" alt="{{ m.title }}"> | ||||
|                     {% else %} | ||||
|                     <img src="https://via.placeholder.com/300x450?text=No+Poster" alt=""> | ||||
|                     {% 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.digitalRelease %}<br>Digital: <span data-dt="{{ m.digitalRelease }}"></span>{% endif %} | ||||
|                         {% if m.physicalRelease %}<br>Disc: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %} | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 {% empty %} | ||||
|                 <p class="muted">Keine Filme gefunden.</p> | ||||
|                 {% endfor %} | ||||
|             </div> | ||||
|         </div> | ||||
|         {% endif %} | ||||
|     </div> | ||||
|  | ||||
|     <!-- Modal --> | ||||
|     <div id="modalBackdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-hidden="true"> | ||||
|         <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="Schließen" aria-label="Schließen">×</button> | ||||
|             </div> | ||||
|  | ||||
|             <div class="modal-body"> | ||||
|                 <div class="section-block"> | ||||
|                     <div class="section-title">Beschreibung</div> | ||||
|                     <div id="mOverview" class="desc muted"></div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="section-block"> | ||||
|                     <div class="section-title">Kommende Episoden</div> | ||||
|                     <div class="section-divider"></div> | ||||
|                     <div id="mEpisodes"></div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <script> | ||||
|         (function () { | ||||
|             // ===== Helpers ===== | ||||
|             const $ = (sel, root = document) => root.querySelector(sel); | ||||
|             const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); | ||||
|  | ||||
|             // ===== Modal-Elemente ===== | ||||
|             const backdrop = $("#modalBackdrop"); | ||||
|             const closeBtn = backdrop.querySelector(".modal-close"); | ||||
|             const mPoster = $("#mPoster"); | ||||
|             const mTitle = $("#mTitle"); | ||||
|             const mOverview = $("#mOverview"); | ||||
|             const mEpisodes = $("#mEpisodes"); | ||||
|             const mBadges = $("#mBadges"); | ||||
|             const mSub = $("#mSub"); | ||||
|             const epSection = mEpisodes.closest(".section-block"); | ||||
|             const subscribeBtn = $("#subscribeBtn"); | ||||
|  | ||||
|             let lastClickedCard = null; | ||||
|  | ||||
|             // ===== Modal open/close ===== | ||||
|             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(); }); | ||||
|  | ||||
|             // ===== Subscribe-Only-UI (mit localStorage) ===== | ||||
|             function subKey(card) { | ||||
|                 if (!card) return null; | ||||
|                 if (card.classList.contains("card") && card.dataset.seriesId) return "series:" + card.dataset.seriesId; | ||||
|                 return "movie:" + (card.dataset.title || ""); | ||||
|             } | ||||
|             function loadSub(card) { | ||||
|                 const k = subKey(card); | ||||
|                 return k ? localStorage.getItem("sub:" + k) === "1" : false; | ||||
|             } | ||||
|             function saveSub(card, on) { | ||||
|                 const k = subKey(card); | ||||
|                 if (!k) return; | ||||
|                 if (on) localStorage.setItem("sub:" + k, "1"); | ||||
|                 else localStorage.removeItem("sub:" + k); | ||||
|             } | ||||
|             function applySubUI(card, on) { | ||||
|                 if (card) card.classList.toggle("subscribed", !!on); // grüne Outline via .subscribed (CSS) | ||||
|                 if (subscribeBtn) { | ||||
|                     subscribeBtn.textContent = on ? "Unsubscribe" : "Subscribe"; | ||||
|                     subscribeBtn.setAttribute("aria-pressed", on ? "true" : "false"); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Beim Laden: gespeicherten Zustand auf alle Karten anwenden | ||||
|             $$(".card, .movie-card").forEach(c => applySubUI(c, loadSub(c))); | ||||
|  | ||||
|             // ===== Serien-Karten öffnen ===== | ||||
|             $$(".card").forEach(card => { | ||||
|                 card.addEventListener("click", () => { | ||||
|                     lastClickedCard = card; | ||||
|  | ||||
|                     const id = card.dataset.seriesId; | ||||
|                     const title = card.dataset.title || ""; | ||||
|                     const poster = card.dataset.poster || ""; | ||||
|                     const overview = card.dataset.overview || ""; | ||||
|  | ||||
|                     // Episoden aus eingebettetem 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 | ||||
|                     mTitle.textContent = title; | ||||
|                     mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster"; | ||||
|                     mPoster.alt = title; | ||||
|                     mOverview.textContent = overview || "Keine Beschreibung verfügbar."; | ||||
|  | ||||
|                     mSub.textContent = episodes.length | ||||
|                         ? `${episodes.length} kommende Episode(n)` | ||||
|                         : "Keine kommenden Episoden"; | ||||
|  | ||||
|                     // Genres-Badges, falls data-genres vorhanden | ||||
|                     mBadges.innerHTML = ""; | ||||
|                     if (card.dataset.genres) { | ||||
|                         card.dataset.genres.split(",").map(s => s.trim()).filter(Boolean).forEach(g => { | ||||
|                             const b = document.createElement("span"); | ||||
|                             b.className = "badge"; | ||||
|                             b.textContent = g; | ||||
|                             mBadges.appendChild(b); | ||||
|                         }); | ||||
|                     } | ||||
|  | ||||
|                     // Episodenbereich | ||||
|                     epSection.style.display = ""; | ||||
|                     mEpisodes.innerHTML = ""; | ||||
|                     if (!episodes.length) { | ||||
|                         const p = document.createElement("p"); | ||||
|                         p.className = "muted"; | ||||
|                         p.textContent = "—"; | ||||
|                         mEpisodes.appendChild(p); | ||||
|                     } else { | ||||
|                         episodes.forEach(e => { | ||||
|                             const row = document.createElement("div"); | ||||
|                             row.className = "ep-row"; | ||||
|                             const dt = e.airDateUtc ? new Date(e.airDateUtc) : null; | ||||
|                             const when = dt && !isNaN(dt) ? dt.toLocaleString() : "-"; | ||||
|                             row.innerHTML = `<strong>S${e.seasonNumber}E${e.episodeNumber}</strong> — ${e.title ?? "(tba)"}<br><span class="muted">${when}</span>`; | ||||
|                             mEpisodes.appendChild(row); | ||||
|                         }); | ||||
|                     } | ||||
|  | ||||
|                     // Subscribe-UI für diese Karte setzen | ||||
|                     applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|  | ||||
|                     openModal(); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             // ===== Film-Karten öffnen ===== | ||||
|             $$(".movie-card").forEach(card => { | ||||
|                 card.addEventListener("click", () => { | ||||
|                     lastClickedCard = card; | ||||
|  | ||||
|                     const title = card.dataset.title || ""; | ||||
|                     const poster = card.dataset.poster || ""; | ||||
|                     const overview = card.dataset.overview || ""; | ||||
|  | ||||
|                     mTitle.textContent = title; | ||||
|                     mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster"; | ||||
|                     mPoster.alt = title; | ||||
|                     mOverview.textContent = overview || "Keine Beschreibung verfügbar."; | ||||
|  | ||||
|                     mSub.textContent = ""; | ||||
|                     mBadges.innerHTML = ""; | ||||
|  | ||||
|                     // Episodenbereich ausblenden | ||||
|                     epSection.style.display = "none"; | ||||
|                     mEpisodes.innerHTML = ""; | ||||
|  | ||||
|                     // Subscribe-UI für diese Karte setzen | ||||
|                     applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|  | ||||
|                     openModal(); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             // ===== Subscribe-Button im Modal toggelt nur UI + localStorage ===== | ||||
|             if (subscribeBtn) { | ||||
|                 subscribeBtn.addEventListener("click", () => { | ||||
|                     if (!lastClickedCard) return; | ||||
|                     const now = !loadSub(lastClickedCard); | ||||
|                     saveSub(lastClickedCard, now); | ||||
|                     applySubUI(lastClickedCard, now); | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             // ===== Datumsangaben in der Übersicht formatieren ===== | ||||
|             document.querySelectorAll("[data-dt]").forEach(el => { | ||||
|                 const v = el.getAttribute("data-dt"); | ||||
|                 if (!v) return; | ||||
|                 const d = new Date(v); | ||||
|                 el.textContent = isNaN(d) ? v : d.toLocaleString(); | ||||
|             }); | ||||
|         })(); | ||||
|     </script> | ||||
|  | ||||
| </body> | ||||
|  | ||||
| </html> | ||||
							
								
								
									
										3
									
								
								arr_api/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								arr_api/tests.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.test import TestCase | ||||
|  | ||||
| # Create your tests here. | ||||
							
								
								
									
										7
									
								
								arr_api/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								arr_api/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| from django.urls import path | ||||
| from .views import SonarrAiringView, RadarrUpcomingMoviesView | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("sonarr/airing", SonarrAiringView.as_view(), name="sonarr-airing"), | ||||
|     path("radarr/upcoming", RadarrUpcomingMoviesView.as_view(), name="radarr-upcoming"), | ||||
| ] | ||||
							
								
								
									
										114
									
								
								arr_api/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								arr_api/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| from collections import defaultdict | ||||
| from django.shortcuts import render | ||||
| from django.views import View | ||||
| from django.contrib import messages | ||||
| from rest_framework.views import APIView | ||||
| from rest_framework.response import Response | ||||
| from rest_framework import status | ||||
|  | ||||
| from settingspanel.models import AppSettings | ||||
| from .services import sonarr_calendar, radarr_calendar, ArrServiceError | ||||
|  | ||||
|  | ||||
| def _get_int(request, key, default): | ||||
|     try: | ||||
|         v = int(request.GET.get(key, default)) | ||||
|         return max(1, min(365, v)) | ||||
|     except (TypeError, ValueError): | ||||
|         return default | ||||
|  | ||||
| def _arr_conf_from_db(): | ||||
|     cfg = AppSettings.current() | ||||
|     return { | ||||
|         "sonarr_url": cfg.sonarr_url, | ||||
|         "sonarr_key": cfg.sonarr_api_key, | ||||
|         "radarr_url": cfg.radarr_url, | ||||
|         "radarr_key": cfg.radarr_api_key, | ||||
|     } | ||||
|  | ||||
|  | ||||
| class SonarrAiringView(APIView): | ||||
|     def get(self, request): | ||||
|         days = _get_int(request, "days", 30) | ||||
|         conf = _arr_conf_from_db() | ||||
|         try: | ||||
|             data = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"]) | ||||
|             return Response({"count": len(data), "results": data}) | ||||
|         except ArrServiceError as e: | ||||
|             return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | ||||
|  | ||||
|  | ||||
| class RadarrUpcomingMoviesView(APIView): | ||||
|     def get(self, request): | ||||
|         days = _get_int(request, "days", 60) | ||||
|         conf = _arr_conf_from_db() | ||||
|         try: | ||||
|             data = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"]) | ||||
|             return Response({"count": len(data), "results": data}) | ||||
|         except ArrServiceError as e: | ||||
|             return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | ||||
|  | ||||
|  | ||||
| class ArrIndexView(View): | ||||
|     def get(self, request): | ||||
|         q = (request.GET.get("q") or "").lower().strip() | ||||
|         kind = (request.GET.get("kind") or "all").lower() | ||||
|         days = _get_int(request, "days", 30) | ||||
|  | ||||
|         conf = _arr_conf_from_db() | ||||
|  | ||||
|         eps, movies = [], [] | ||||
|         # Sonarr robust laden | ||||
|         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}") | ||||
|  | ||||
|         # 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}") | ||||
|  | ||||
|         # Suche | ||||
|         if q: | ||||
|             eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()] | ||||
|             movies = [m for m in movies if q in (m.get("title") or "").lower()] | ||||
|  | ||||
|         # Gruppierung nach Serie | ||||
|         groups = defaultdict(lambda: { | ||||
|             "seriesId": None, "seriesTitle": None, "seriesPoster": None, | ||||
|             "seriesOverview": "", "seriesGenres": [], "episodes": [], | ||||
|         }) | ||||
|         for e in eps: | ||||
|             sid = e["seriesId"] | ||||
|             g = groups[sid] | ||||
|             g["seriesId"] = sid | ||||
|             g["seriesTitle"] = e["seriesTitle"] | ||||
|             g["seriesPoster"] = g["seriesPoster"] or e.get("seriesPoster") | ||||
|             if not g["seriesOverview"] and e.get("seriesOverview"): | ||||
|                 g["seriesOverview"] = e["seriesOverview"] | ||||
|             if not g["seriesGenres"] and e.get("seriesGenres"): | ||||
|                 g["seriesGenres"] = e["seriesGenres"] | ||||
|             g["episodes"].append({ | ||||
|                 "episodeId": e["episodeId"], | ||||
|                 "seasonNumber": e["seasonNumber"], | ||||
|                 "episodeNumber": e["episodeNumber"], | ||||
|                 "title": e["title"], | ||||
|                 "airDateUtc": e["airDateUtc"], | ||||
|             }) | ||||
|  | ||||
|         series_grouped = [] | ||||
|         for g in groups.values(): | ||||
|             g["episodes"].sort(key=lambda x: (x["airDateUtc"] or "")) | ||||
|             series_grouped.append(g) | ||||
|  | ||||
|         return render(request, "arr_api/index.html", { | ||||
|             "query": q, | ||||
|             "kind": kind, | ||||
|             "days": days, | ||||
|             "show_series": kind in ("all", "series"), | ||||
|             "show_movies": kind in ("all", "movies"), | ||||
|             "series_grouped": series_grouped, | ||||
|             "movies": movies, | ||||
|         }) | ||||
		Reference in New Issue
	
	Block a user