feat: redesign dashboard with Tracearr-inspired layout and improve language detection
This commit is contained in:
1 parent
00b841fa67
commit
15310c5e61
8 files changed
+894
-313
No files matched your search
@@ -4,6 +4,7 @@ Media Max ist ein kompaktes Media-Control-Dashboard für Radarr, Sonarr und SABn
|
||||
|
||||
## Funktionen
|
||||
|
||||
- Tracearr-inspirierter Aufbau mit linker Navigation, dunkler Oberfläche und übersichtlichen Statuskarten
|
||||
- Radarr-Filme mit Qualität, Status, Audio, Untertiteln, Codec und Größe
|
||||
- Sonarr-Serien mit Episodenansicht und Kategorien über Tags:
|
||||
- `anime` → Anime
|
||||
@@ -12,6 +13,8 @@ Media Max ist ein kompaktes Media-Control-Dashboard für Radarr, Sonarr und SABn
|
||||
- Separate Liste „Ohne Deutsch“ für vorhandene oder aktuell geladene Medien
|
||||
- Staffel 0 kann in den Einstellungen ein- oder ausgeblendet werden
|
||||
- Externe Untertitel (`.srt`, `.ass`, `.ssa`, `.vtt`, `.sub`, `.idx`, `.sup`) werden berücksichtigt
|
||||
- Spracherkennung über Sprach-Tags und eindeutige Audio-/Untertitel-Titel, inklusive regionaler Codes (`de-DE`, `pt-BR`)
|
||||
- Ersatzweise Dateimetadaten aus Radarr/Sonarr (mit `*` markiert); unbekannte Spuren werden nicht geraten
|
||||
- SABnzbd-Downloadübersicht mit aktiven Downloads und separater Queue-/Verifying-Tabelle
|
||||
- Download-Sprachen aus üblichen Release-Kürzeln wie `Eng`, `Ger`, `Fre`, `Ita` und `Spa`
|
||||
- Dashboard mit Diensten, CPU, RAM, Speicher, I/O Pressure und Bibliotheksstatistiken
|
||||
@@ -96,6 +99,7 @@ GPU_REFRESH_SECONDS=5
|
||||
```
|
||||
|
||||
`SCAN_WORKERS` steuert die Anzahl paralleler `ffprobe`-Jobs. Höhere Werte beschleunigen große Bibliotheken, erhöhen aber die I/O-Last.
|
||||
Nach einem Scanner-Update werden ältere Cache-Einträge automatisch neu analysiert. Fehlgeschlagene Analysen können beim nächsten Bibliotheks-Sync nach mindestens fünf Minuten erneut versucht werden. Fehlerhafte oder noch nicht gescannte Dateien erscheinen nicht fälschlich in „Ohne Deutsch“.
|
||||
`SYNC_INTERVAL_SECONDS` steuert den serverseitigen Sync-Takt; `1800` entspricht 30 Minuten, `18000` fünf Stunden und `0` deaktiviert die periodische Wiederholung (der Start-Sync bleibt aktiv).
|
||||
RAID, I/O-Pressure und GPU werden unabhängig vom Browser durch eigene Hintergrund-Monitoren aktualisiert. Die Werte werden aus dem Server-Cache geliefert; die drei Intervalle können separat angepasst werden.
|
||||
|
||||
@@ -175,6 +179,8 @@ Für Radarr, Sonarr und die weiteren Container wird das externe Netzwerk `media-
|
||||
|
||||
## Fehlersuche
|
||||
|
||||
„Unbekannt“ bedeutet, dass eine Spur weder einen verwertbaren Sprach-Tag noch einen eindeutigen Spurtitel besitzt. Wenn auch Radarr/Sonarr keine Dateisprachen liefern, kann die App die Sprache nicht zuverlässig bestimmen. Die Originalsprache eines Films und Release-Kürzel wie `DL` sind kein Nachweis für die tatsächlichen Audiospuren.
|
||||
|
||||
Prüfe zuerst, ob Media Max die Medien sieht:
|
||||
|
||||
```bash
|
||||
@@ -200,3 +206,12 @@ smartctl -A -d sat /host-dev/sda
|
||||
```
|
||||
|
||||
Wenn eine PWA-Installation in Chrome nicht angeboten wird, muss Media Max über gültiges HTTPS aufgerufen werden. Nach Änderungen an Manifest oder Service Worker sollten alte Website-Daten bzw. eine alte Verknüpfung entfernt und die Seite neu geladen werden.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests -v
|
||||
node --check static/dashboard.js
|
||||
```
|
||||
|
||||
Die Tests verwenden gemockte Dienste und benötigen keine Medienbibliothek oder API-Schlüssel.
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
import shutil
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
@@ -99,8 +99,42 @@ hardware_log_once = set()
|
||||
library_cache_lock = threading.Lock()
|
||||
library_cache = {"rows": [], "series": [], "updated_at": 0.0, "error": None, "ready": False}
|
||||
|
||||
LANG_NAMES = {"de":"Deutsch", "deu":"Deutsch", "ger":"Deutsch", "en":"Englisch", "eng":"Englisch", "ja":"Japanisch", "jpn":"Japanisch", "ko":"Koreanisch", "kor":"Koreanisch", "fr":"Französisch", "fra":"Französisch", "fre":"Französisch", "es":"Spanisch", "spa":"Spanisch", "it":"Italienisch", "ita":"Italienisch", "ru":"Russisch", "rus":"Russisch", "zh":"Chinesisch", "zho":"Chinesisch", "chi":"Chinesisch", "und":"Unbekannt", "":"Unbekannt"}
|
||||
FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":"❓"}
|
||||
LANGUAGE_DEFINITIONS = [
|
||||
("de", "Deutsch", "🇩🇪", "deu ger german deutsch"),
|
||||
("en", "Englisch", "🇬🇧", "eng english englisch"),
|
||||
("ja", "Japanisch", "🇯🇵", "jpn japanese japanisch"),
|
||||
("ko", "Koreanisch", "🇰🇷", "kor korean koreanisch"),
|
||||
("fr", "Französisch", "🇫🇷", "fra fre french français francais französisch"),
|
||||
("es", "Spanisch", "🇪🇸", "spa spanish spanisch español espanol"),
|
||||
("it", "Italienisch", "🇮🇹", "ita italian italienisch"),
|
||||
("ru", "Russisch", "🇷🇺", "rus russian russisch"),
|
||||
("zh", "Chinesisch", "🇨🇳", "zho chi chinese chinesisch mandarin cmn cantonese yue"),
|
||||
("pt", "Portugiesisch", "🇵🇹", "por portuguese portugiesisch português portugues"),
|
||||
("nl", "Niederländisch", "🇳🇱", "nld dut dutch niederländisch"),
|
||||
("pl", "Polnisch", "🇵🇱", "pol polish polnisch"),
|
||||
("tr", "Türkisch", "🇹🇷", "tur turkish türkisch"),
|
||||
("ar", "Arabisch", "🇸🇦", "ara arabic arabisch"),
|
||||
("hi", "Hindi", "🇮🇳", "hin hindi"),
|
||||
("sv", "Schwedisch", "🇸🇪", "swe swedish schwedisch"),
|
||||
("da", "Dänisch", "🇩🇰", "dan danish dänisch"),
|
||||
("no", "Norwegisch", "🇳🇴", "nor nob nno nb nn norwegian norwegisch"),
|
||||
("fi", "Finnisch", "🇫🇮", "fin finnish finnisch"),
|
||||
("cs", "Tschechisch", "🇨🇿", "ces cze czech tschechisch"),
|
||||
("el", "Griechisch", "🇬🇷", "ell gre greek griechisch"),
|
||||
("hu", "Ungarisch", "🇭🇺", "hun hungarian ungarisch"),
|
||||
("uk", "Ukrainisch", "🇺🇦", "ukr ukrainian ukrainisch"),
|
||||
("ro", "Rumänisch", "🇷🇴", "ron rum romanian rumänisch"),
|
||||
("th", "Thai", "🇹🇭", "tha thai"),
|
||||
("vi", "Vietnamesisch", "🇻🇳", "vie vietnamese vietnamesisch"),
|
||||
("id", "Indonesisch", "🇮🇩", "ind indonesian indonesisch"),
|
||||
("he", "Hebräisch", "🇮🇱", "heb hebrew hebräisch"),
|
||||
]
|
||||
LANG_ALIASES = {alias: code for code, name, flag, aliases in LANGUAGE_DEFINITIONS
|
||||
for alias in (code, name.casefold(), *aliases.split())}
|
||||
LANG_NAMES = {code: name for code, name, flag, aliases in LANGUAGE_DEFINITIONS}
|
||||
LANG_NAMES["und"] = "Unbekannt"
|
||||
FLAGS = {name: flag for code, name, flag, aliases in LANGUAGE_DEFINITIONS}
|
||||
FLAGS["Unbekannt"] = "❓"
|
||||
|
||||
SERVICE_DEFINITIONS = [
|
||||
{"key": "radarr", "name": "Radarr", "url": f"{RADARR_URL}/api/v3/system/status", "api_key": RADARR_API_KEY, "kind": "api"},
|
||||
@@ -767,7 +801,7 @@ def system_metrics():
|
||||
return {"cpu_percent": cpu_usage_percent(), "memory": {"used": format_bytes(used_memory), "total": format_bytes(total_memory), "percent": round(used_memory / total_memory * 100, 1) if total_memory else 0}, "disks": disks, "load": [round(value, 2) for value in load], "uptime_seconds": round(uptime_seconds), "processes": process_count, "timestamp": time.time()}
|
||||
|
||||
def release_languages(name):
|
||||
value = f" {name.lower().replace('.', ' ').replace('-', ' ')} "
|
||||
value = " " + re.sub(r"[\W_]+", " ", str(name or "").casefold()).strip() + " "
|
||||
found = []
|
||||
patterns = [
|
||||
("Deutsch", (" german ", " german dubbed ", " deutsch ", " ger ", " deu ", " german dl ")),
|
||||
@@ -816,9 +850,12 @@ def sab_downloads():
|
||||
"remaining": format_bytes(remaining_bytes), "error": None}
|
||||
|
||||
def norm_lang(code):
|
||||
code = (code or "").strip().lower()
|
||||
name = LANG_NAMES.get(code, code.upper() if code else "Unbekannt")
|
||||
return {"code": code or "und", "name": name, "flag": FLAGS.get(name, "🌐")}
|
||||
value = str(code or "").strip().casefold()
|
||||
code = LANG_ALIASES.get(value) or LANG_ALIASES.get(re.split(r"[-_]", value)[0]) or value
|
||||
if code in {"", "und", "unknown", "undefined", "unbekannt", "unk", "mul", "zxx"}:
|
||||
code = "und"
|
||||
name = LANG_NAMES.get(code, code.upper())
|
||||
return {"code": code, "name": name, "flag": FLAGS.get(name, "🌐")}
|
||||
|
||||
def unique_langs(items):
|
||||
out, seen = [], set()
|
||||
@@ -829,12 +866,13 @@ def unique_langs(items):
|
||||
|
||||
def map_path(path, remote, local):
|
||||
if not path: return None
|
||||
if remote and path.startswith(remote):
|
||||
if remote and (path == remote or path.startswith(remote.rstrip("/") + "/")):
|
||||
return str(Path(local) / path[len(remote):].lstrip("/"))
|
||||
return path
|
||||
|
||||
EXTERNAL_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup"}
|
||||
SCANNER_VERSION = 2
|
||||
SCANNER_VERSION = 3
|
||||
SCAN_ERROR_RETRY_SECONDS = 300
|
||||
|
||||
def external_subtitle_files(path):
|
||||
media = Path(path)
|
||||
@@ -850,60 +888,51 @@ def external_subtitle_languages(path):
|
||||
media = Path(path)
|
||||
languages = []
|
||||
for candidate in external_subtitle_files(path):
|
||||
tokens = {token for token in re.split(r"[. _()\[\]-]+", candidate.stem.lower()) if token}
|
||||
if tokens & {"de", "deu", "ger", "german", "deutsch"}:
|
||||
languages.append(norm_lang("de"))
|
||||
if tokens & {"en", "eng", "english"}:
|
||||
languages.append(norm_lang("en"))
|
||||
if tokens & {"fr", "fra", "fre", "french", "französisch"}:
|
||||
languages.append(norm_lang("fr"))
|
||||
if tokens & {"es", "spa", "spanish", "spanisch"}:
|
||||
languages.append(norm_lang("es"))
|
||||
if tokens & {"it", "ita", "italian", "italienisch"}:
|
||||
languages.append(norm_lang("it"))
|
||||
# Only inspect the suffix, never words from the movie title itself.
|
||||
suffix = candidate.stem[len(media.stem):]
|
||||
languages.extend(languages_from_text(suffix))
|
||||
return unique_langs(languages)
|
||||
|
||||
def languages_from_text(value):
|
||||
tokens = re.findall(r"[^\W_]+", str(value or "").casefold())
|
||||
return unique_langs([norm_lang(LANG_ALIASES[token]) for token in tokens if token in LANG_ALIASES])
|
||||
|
||||
def language_from_text(value):
|
||||
"""Infer a language from a subtitle/audio track title when the codec tag is `und`."""
|
||||
tokens = {token for token in re.split(r"[. _()\[\]-]+", str(value or "").lower()) if token}
|
||||
aliases = {
|
||||
"de": "de", "deu": "de", "ger": "de", "german": "de", "deutsch": "de",
|
||||
"en": "en", "eng": "en", "english": "en",
|
||||
"ja": "ja", "jpn": "ja", "japanese": "ja", "japanisch": "ja",
|
||||
"ko": "ko", "kor": "ko", "korean": "ko", "koreanisch": "ko",
|
||||
"fr": "fr", "fra": "fr", "fre": "fr", "french": "fr", "französisch": "fr",
|
||||
"es": "es", "spa": "es", "spanish": "es", "spanisch": "es",
|
||||
"it": "it", "ita": "it", "italian": "it", "italienisch": "it",
|
||||
}
|
||||
for token in tokens:
|
||||
if token in aliases:
|
||||
return norm_lang(aliases[token])
|
||||
return None
|
||||
"""Use unambiguous track titles; do not guess between multiple languages."""
|
||||
languages = languages_from_text(value)
|
||||
return languages[0] if len(languages) == 1 else None
|
||||
|
||||
def run_ffprobe(path):
|
||||
proc = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=size:stream=index,codec_type,codec_name,channels,channel_layout:stream_tags=language,title", "-of", "json", path], capture_output=True, text=True, timeout=90)
|
||||
if proc.returncode != 0: raise RuntimeError(proc.stderr.strip() or "ffprobe fehlgeschlagen")
|
||||
raw, audios, subs, details, video = json.loads(proc.stdout or "{}"), [], [], [], None
|
||||
for stream in raw.get("streams", []):
|
||||
tags = stream.get("tags") or {}; kind = stream.get("codec_type")
|
||||
tags = {key.casefold(): value for key, value in (stream.get("tags") or {}).items()}; kind = stream.get("codec_type")
|
||||
lang = norm_lang(tags.get("language"))
|
||||
# Viele ASS-Spuren werden von ffprobe nur als `und` gemeldet, obwohl
|
||||
# der Tracktitel z. B. „Deutsch - Undefined - ASS“ enthält.
|
||||
if kind == "subtitle" and lang["name"] == "Unbekannt":
|
||||
if kind in {"audio", "subtitle"} and lang["name"] == "Unbekannt":
|
||||
lang = language_from_text(tags.get("title")) or lang
|
||||
if kind == "video" and not video: video = stream.get("codec_name")
|
||||
elif kind == "audio":
|
||||
audios.append(lang); details.append({"language": lang, "codec": stream.get("codec_name"), "channels": stream.get("channels"), "layout": stream.get("channel_layout"), "title": tags.get("title")})
|
||||
source = "title" if norm_lang(tags.get("language"))["code"] == "und" and lang["code"] != "und" else "tag"
|
||||
audios.append(lang); details.append({"language": lang, "language_source": source, "codec": stream.get("codec_name"), "channels": stream.get("channels"), "layout": stream.get("channel_layout"), "title": tags.get("title")})
|
||||
elif kind == "subtitle": subs.append(lang)
|
||||
subtitles = unique_langs(subs + external_subtitle_languages(path))
|
||||
return {"scanner_version": SCANNER_VERSION, "audio_languages": unique_langs(audios), "subtitle_languages": subtitles, "external_subtitle_files": [str(file) for file in external_subtitle_files(path)], "audio_details": details, "video_codec": video or "—", "size": int((raw.get("format") or {}).get("size") or 0)}
|
||||
|
||||
def cached(path):
|
||||
p = Path(path)
|
||||
if not p.exists(): return {"error": f"Datei nicht gefunden: {p}", "pending": True}
|
||||
mtime = p.stat().st_mtime; con = db(); row = con.execute("SELECT mtime,data FROM media_cache WHERE path=?", (str(p),)).fetchone(); con.close()
|
||||
if not p.exists(): return {"error": f"Datei nicht gefunden: {p}", "pending": False}
|
||||
mtime = p.stat().st_mtime; con = db(); row = con.execute("SELECT mtime,data,scanned_at FROM media_cache WHERE path=?", (str(p),)).fetchone(); con.close()
|
||||
if row and float(row["mtime"]) == float(mtime):
|
||||
data = json.loads(row["data"])
|
||||
if data.get("error"):
|
||||
scanned_at = datetime.fromisoformat(row["scanned_at"])
|
||||
if scanned_at.tzinfo is None:
|
||||
scanned_at = scanned_at.replace(tzinfo=timezone.utc)
|
||||
if (datetime.now(timezone.utc) - scanned_at).total_seconds() >= SCAN_ERROR_RETRY_SECONDS:
|
||||
return None
|
||||
current_subtitles = [str(file) for file in external_subtitle_files(path)]
|
||||
if data.get("scanner_version") == SCANNER_VERSION and "external_subtitle_files" in data and data["external_subtitle_files"] == current_subtitles:
|
||||
return data
|
||||
@@ -916,8 +945,8 @@ def scan_one(path):
|
||||
try:
|
||||
data = run_ffprobe(str(p)); log.info("Gescannt: %s", p)
|
||||
except Exception as exc:
|
||||
# Cache failed probes as completed attempts. A changed mtime will
|
||||
# invalidate this entry automatically and trigger a retry later.
|
||||
# Avoid immediate retry loops, but retry transient failures on a
|
||||
# later sync even when the file itself has not changed.
|
||||
data = {
|
||||
"scanner_version": SCANNER_VERSION,
|
||||
"audio_languages": [], "subtitle_languages": [],
|
||||
@@ -926,7 +955,7 @@ def scan_one(path):
|
||||
}
|
||||
log.error("Scanfehler für %s: %s", p, error_summary(exc))
|
||||
mtime = p.stat().st_mtime
|
||||
con = db(); con.execute("INSERT INTO media_cache(path,mtime,data,scanned_at) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime,data=excluded.data,scanned_at=excluded.scanned_at", (str(p), mtime, json.dumps(data), datetime.utcnow().isoformat())); con.commit(); con.close()
|
||||
con = db(); con.execute("INSERT INTO media_cache(path,mtime,data,scanned_at) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime,data=excluded.data,scanned_at=excluded.scanned_at", (str(p), mtime, json.dumps(data), datetime.now(timezone.utc).isoformat())); con.commit(); con.close()
|
||||
return data
|
||||
|
||||
def format_size(size):
|
||||
@@ -940,7 +969,8 @@ def language_state(audios):
|
||||
names = {x["name"] for x in audios}
|
||||
if {"Deutsch", "Englisch"} <= names: return {"class":"ok", "label":"DE + EN"}
|
||||
if "Deutsch" in names: return {"class":"warn", "label":"Deutsch"}
|
||||
if "Englisch" in names: return {"class":"bad", "label":"English only"}
|
||||
if "Unbekannt" in names: return {"class":"neutral", "label":"Sprache prüfen"}
|
||||
if names == {"Englisch"}: return {"class":"bad", "label":"English only"}
|
||||
return {"class":"neutral", "label":"Andere"}
|
||||
|
||||
def has_german_track(row):
|
||||
@@ -951,11 +981,35 @@ def is_german_review_candidate(row):
|
||||
"""Only review media that exists or is currently being downloaded."""
|
||||
return not row.get("missing") or bool(row.get("downloading"))
|
||||
|
||||
def needs_german_review(row):
|
||||
# Failed and unfinished scans cannot establish that German is absent.
|
||||
return is_german_review_candidate(row) and not row.get("pending") and not row.get("error") and not has_german_track(row)
|
||||
|
||||
def file_metadata_languages(media_file):
|
||||
"""Read file-level Arr metadata, never the movie's original language."""
|
||||
media_file = media_file or {}
|
||||
values = (media_file.get("mediaInfo") or {}).get("audioLanguages") or []
|
||||
if isinstance(values, str):
|
||||
values = re.split(r"[,/;|]+", values)
|
||||
languages = []
|
||||
for value in values:
|
||||
language = norm_lang(value)
|
||||
if language["code"] in LANG_NAMES and language["code"] != "und":
|
||||
languages.append(language)
|
||||
if not languages:
|
||||
for value in media_file.get("languages") or []:
|
||||
language = norm_lang(value.get("name") if isinstance(value, dict) else value)
|
||||
if language["code"] in LANG_NAMES and language["code"] != "und":
|
||||
languages.append(language)
|
||||
return [{**language, "source": "arr"} for language in unique_langs(languages)]
|
||||
|
||||
def media_row(name, year, media_file, remote, local, extra=None):
|
||||
path = media_file.get("path") if media_file else None
|
||||
if not path and media_file and media_file.get("relativePath"): path = str(Path(media_file.get("basePath") or "") / media_file["relativePath"])
|
||||
local_path = map_path(path, remote, local); info = cached(local_path) if local_path else {"error":"Kein Dateipfad erhalten"}
|
||||
info = info or {"pending": True}; audios = info.get("audio_languages", [])
|
||||
info = dict(info or {"pending": True}); audios = info.get("audio_languages", [])
|
||||
if media_file and (not audios or all(language["code"] == "und" for language in audios)):
|
||||
audios = file_metadata_languages(media_file) or audios
|
||||
if local_path:
|
||||
info["subtitle_languages"] = unique_langs(info.get("subtitle_languages", []) + external_subtitle_languages(local_path))
|
||||
downloading = bool((extra or {}).get("downloading"))
|
||||
@@ -1025,6 +1079,7 @@ def start_scan(source, force=False, rows_snapshot=None, series_snapshot=None, pe
|
||||
if SONARR_URL and SONARR_API_KEY:
|
||||
for group in series_snapshot if series_snapshot is not None else sonarr_groups():
|
||||
paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "—" and is_german_review_candidate(r) and not r.get("downloading") and not has_german_track(r) and (not pending_only or r.get("pending")))
|
||||
paths = list(dict.fromkeys(paths))
|
||||
with job_lock: jobs[source]["total"] = len(paths)
|
||||
futures = [scan_executor.submit(scan_one, path) for path in paths]
|
||||
for future in as_completed(futures):
|
||||
@@ -1066,15 +1121,8 @@ def refresh_library_cache():
|
||||
start_scan("radarr", force=True, rows_snapshot=rows)
|
||||
if series and any(row.get("pending") for group in series for row in group["episodes"]):
|
||||
start_scan("sonarr", force=True, series_snapshot=series)
|
||||
needs_missing_scan = any(
|
||||
row.get("local_path") != "—" and is_german_review_candidate(row) and row.get("pending") and not has_german_track(row)
|
||||
for row in rows
|
||||
) or any(
|
||||
episode.get("local_path") != "—" and is_german_review_candidate(episode) and episode.get("pending") and not has_german_track(episode)
|
||||
for group in series for episode in group["episodes"]
|
||||
)
|
||||
if needs_missing_scan:
|
||||
start_scan("missing", force=True, rows_snapshot=rows, series_snapshot=series, pending_only=True)
|
||||
# Radarr/Sonarr already scan every pending file. Starting a missing-German
|
||||
# job here too would probe the same files twice in parallel.
|
||||
log.info("Bibliotheks-Sync abgeschlossen: %s Filme, %s Serien", len(rows), len(series))
|
||||
|
||||
def background_library_sync():
|
||||
@@ -1098,12 +1146,14 @@ def index():
|
||||
rows = list(library_cache["rows"])
|
||||
series = list(library_cache["series"])
|
||||
error = library_cache["error"]
|
||||
library_updated_at = library_cache["updated_at"]
|
||||
library_ready = library_cache["ready"]
|
||||
# Unscanned files have no language information yet and must not be shown
|
||||
# as missing German while the background scanner is still processing them.
|
||||
missing_german_movies = [row for row in rows if is_german_review_candidate(row) and not row.get("pending") and not has_german_track(row)]
|
||||
missing_german_movies = [row for row in rows if needs_german_review(row)]
|
||||
missing_german_series = []
|
||||
for group in series:
|
||||
episodes = [episode for episode in group["episodes"] if is_german_review_candidate(episode) and not episode.get("pending") and not has_german_track(episode)]
|
||||
episodes = [episode for episode in group["episodes"] if needs_german_review(episode)]
|
||||
if episodes:
|
||||
missing_german_series.append({**group, "episodes": episodes})
|
||||
series_categories = [
|
||||
@@ -1120,10 +1170,22 @@ def index():
|
||||
"episode_files": sum(1 for group in series for row in group["episodes"] if not row.get("missing")),
|
||||
"episode_missing": sum(1 for group in series for row in group["episodes"] if row.get("missing") and not row.get("downloading")),
|
||||
}
|
||||
existing = [row for row in rows + [episode for group in series for episode in group["episodes"]] if not row.get("missing")]
|
||||
language_review = [row for row in existing if row.get("error") or any(language["code"] == "und" for language in row.get("audio_languages", []))]
|
||||
dashboard_stats.update({
|
||||
"german_tracks": sum(1 for row in existing if has_german_track(row)),
|
||||
"unknown_audio": sum(1 for row in existing if any(language["code"] == "und" for language in row.get("audio_languages", []))),
|
||||
"pending": sum(1 for row in existing if row.get("pending")),
|
||||
"scan_errors": sum(1 for row in existing if row.get("error")),
|
||||
"existing_total": len(existing),
|
||||
})
|
||||
return render_template("index.html", rows=rows, series=series, series_categories=series_categories,
|
||||
missing_german_movies=missing_german_movies,
|
||||
missing_german_series=missing_german_series, error=error,
|
||||
dashboard_stats=dashboard_stats,
|
||||
language_names=sorted(set(LANG_NAMES.values())),
|
||||
language_review=language_review[:5], library_updated_at=library_updated_at,
|
||||
library_ready=library_ready, sync_interval_seconds=SYNC_INTERVAL_SECONDS,
|
||||
sonarr_enabled=bool(SONARR_URL and SONARR_API_KEY), sab_enabled=bool(SAB_URL and SAB_API_KEY),
|
||||
radarr_media_path=RADARR_MEDIA_PATH, local_media_path=LOCAL_MEDIA_PATH,
|
||||
local_series_path=LOCAL_SERIES_PATH)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0c0d0f;
|
||||
--panel: #111316;
|
||||
--sidebar: #181a1d;
|
||||
--line: #292c31;
|
||||
--text: #ededf0;
|
||||
--muted: #969ba5;
|
||||
--accent: #17c7df;
|
||||
--green: #58caa0;
|
||||
--amber: #e9b765;
|
||||
--red: #ed858b;
|
||||
--purple: #b397e9;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
||||
button, input, select { font: inherit; }
|
||||
button, select, input { color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 7px; }
|
||||
button { cursor: pointer; padding: 8px 12px; }
|
||||
button:hover { background: #22262b; }
|
||||
button:disabled { opacity: .55; cursor: wait; }
|
||||
button:focus-visible, a:focus-visible, summary:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 { font-size: 27px; line-height: 1.25; font-weight: 650; letter-spacing: -.7px; }
|
||||
h2 { font-size: 18px; font-weight: 600; letter-spacing: -.3px; }
|
||||
h3 { font-size: 14px; font-weight: 600; }
|
||||
small { font-size: 12px; }
|
||||
code { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.icon { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.svg-definitions { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||
.muted, .year, .metadata-note, .panel-note { color: var(--muted); }
|
||||
.text-green { color: var(--green); }.text-amber { color: var(--amber); }.text-red { color: var(--red); }
|
||||
.hidden, .view:not(.active) { display: none !important; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.skip-link { position: fixed; left: 20px; top: -60px; z-index: 100; padding: 10px; background: var(--accent); color: #03171c; border-radius: 7px; }
|
||||
.skip-link:focus { top: 10px; }
|
||||
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 40; display: flex; flex-direction: column; width: 236px; background: var(--sidebar); border-right: 1px solid var(--line); }
|
||||
.brand { height: 76px; display: flex; align-items: center; gap: 11px; padding: 0 24px; font-size: 23px; font-weight: 750; letter-spacing: -.6px; }
|
||||
.brand-icon { width: 32px; height: 32px; padding: 5px; color: var(--accent); background: #143037; border-radius: 9px; }
|
||||
.brand-accent { font-weight: 500; color: #c9cdd4; }
|
||||
.workspace { display: flex; align-items: center; gap: 12px; padding: 17px 23px; margin-bottom: 9px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.workspace > .icon { color: var(--muted); }.workspace strong { display: block; font-size: 12px; font-weight: 600; }.workspace small { display: block; color: var(--muted); font-size: 10px; }.workspace .status-dot { margin-left: auto; }
|
||||
.status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--green); flex: 0 0 auto; }
|
||||
.nav { padding: 0 12px; overflow-y: auto; }
|
||||
.nav-label { padding: 18px 11px 8px; font-size: 11px; color: #8e949e; font-weight: 500; }
|
||||
.nav button, .sidebar-bottom > button { display: flex; align-items: center; gap: 11px; width: 100%; border: none; padding: 11px 12px; margin: 2px 0; text-align: left; background: transparent; color: #b8bdc6; font-size: 13px; }
|
||||
.nav button:hover, .sidebar-bottom > button:hover { background: #25282e; color: var(--text); }
|
||||
.nav button.active { color: #001c22; background: var(--accent); font-weight: 650; }
|
||||
.nav button.active .nav-count { background: #001c2220; color: #001c22; }
|
||||
.nav-count { margin-left: auto; padding: 1px 7px; background: #2b2e34; border-radius: 5px; color: #c5c9d2; font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.sidebar-bottom { margin-top: auto; padding: 16px 12px 19px; }.sidebar-bottom > button { padding: 11px; }
|
||||
.sidebar-footer { border-top: 1px solid var(--line); margin: 12px 9px 0; padding-top: 16px; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 10px; }.sidebar-footer > span:last-child { margin-left: auto; font-size: 9px; }
|
||||
.app-shell { margin-left: 236px; min-width: 0; }
|
||||
.topbar { display: flex; align-items: center; gap: 12px; height: 65px; padding: 0 30px; position: sticky; top: 0; background: var(--bg); border-bottom: 1px solid var(--line); z-index: 20; }
|
||||
.breadcrumb { display: flex; gap: 13px; color: #7d8490; font-size: 12px; white-space: nowrap; }.breadcrumb strong { color: #c5cbd3; font-weight: 500; }.breadcrumb > span { color: #454a54; }
|
||||
.actions { display: flex; align-items: center; gap: 14px; margin-left: auto; }
|
||||
.icon-button { display: inline-grid; place-items: center; width: 34px; height: 34px; padding: 0; font-size: 11px; font-weight: 650; }
|
||||
.scan-control { display: flex; gap: 7px; align-items: center; }.scan-control select { height: 34px; padding: 0 8px; font-size: 11px; }
|
||||
.primary-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; border-color: #17c7df; background: var(--accent); color: #001c22; font-size: 12px; font-weight: 650; }.primary-button:hover { background: #54d8e9; }.primary-button .icon { width: 16px; height: 16px; }
|
||||
.menu-toggle, .sidebar-shade { display: none; }
|
||||
main { max-width: 1800px; margin: 0 auto; padding: 29px 30px 48px; }
|
||||
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 25px; }.page-heading p { margin-top: 7px; color: var(--muted); font-size: 12px; }.page-heading > .icon { color: var(--accent); }
|
||||
.live-label { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--muted); white-space: nowrap; }.source-label { color: #b6bdc9; border: 1px solid var(--line); border-radius: 5px; padding: 4px 9px; font-size: 11px; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 29px; }
|
||||
.summary-card { display: flex; align-items: center; gap: 14px; padding: 19px 17px; min-height: 108px; text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; }
|
||||
.summary-card[data-go]:hover { border-color: #476169; background: #151a1e; }.summary-card:not([data-go]) { cursor: default; }
|
||||
.summary-card > div { min-width: 0; }.summary-card strong { display: block; font-size: 26px; font-weight: 650; line-height: 1.15; letter-spacing: -.7px; font-variant-numeric: tabular-nums; }.summary-card > div > span { display: block; color: #b5bbc5; font-size: 12px; margin-top: 5px; }.summary-card small { display: block; color: #767f8e; font-size: 10px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.summary-icon { display: grid; place-items: center; width: 40px; height: 40px; flex: 0 0 auto; border-radius: 8px; background: #0d272e; color: var(--accent); }.summary-icon.purple { background: #252037; color: var(--purple); }.summary-icon.amber { background: #302719; color: var(--amber); }.summary-icon.green { background: #142c24; color: var(--green); }
|
||||
.section-title { display: flex; align-items: center; gap: 10px; margin: 24px 0 14px; }.section-title > .icon { color: var(--accent); }.section-title > .muted { margin-left: auto; font-size: 10px; }.section-title .badge { margin-left: 3px; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; }.system-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); }
|
||||
.panel { min-width: 0; padding: 20px; border: 1px solid var(--line); background: var(--panel); border-radius: 9px; }
|
||||
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 21px; }.panel-heading > .icon { color: #686f7b; }.panel-heading > .muted { font-size: 10px; }
|
||||
.metric-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 12px; }.metric-line strong { font-weight: 550; font-variant-numeric: tabular-nums; }
|
||||
.track { height: 6px; border-radius: 4px; overflow: hidden; background: #252930; margin: 10px 0 8px; }.track > span { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--accent); transition: width .4s ease; }.purple-track > span { background: var(--purple); }
|
||||
.coverage-row + .coverage-row { margin-top: 23px; }.coverage-row > small { font-size: 10px; }
|
||||
.library-footer { display: flex; align-items: center; gap: 7px; border-top: 1px solid var(--line); margin-top: 25px; padding-top: 13px; color: var(--muted); font-size: 10px; }.library-footer .icon { width: 13px; height: 13px; }
|
||||
.language-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }.language-metrics strong { display: block; font-size: 25px; font-weight: 600; }.language-metrics small { display: block; font-size: 10px; color: var(--muted); }
|
||||
.panel-note { font-size: 11px; margin-top: 17px; line-height: 1.7; }.review-list { margin-top: 13px; border-top: 1px solid var(--line); padding-top: 7px; }.review-list > div { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 5px 0; }.review-list .title { color: #bdc3ce; font-size: 11px; }
|
||||
.system-meters { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }.system-meters .metric-line strong { font-size: 18px; }.system-meters small { font-size: 10px; }
|
||||
.system-details { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; border-top: 1px solid var(--line); padding-top: 17px; margin-top: 19px; }.system-details small { display: block; font-size: 10px; color: var(--muted); }.system-details strong { display: block; font-weight: 500; font-size: 12px; margin-top: 4px; }
|
||||
.disk-list { margin-top: 18px; }.disk-row { display: grid; grid-template-columns: 74px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--line); font-size: 10px; }.disk-row small { display: block; font-size: 9px; color: var(--muted); }.disk-row .track { margin: 0; }.disk-values { text-align: right; }
|
||||
.service-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 22px; }.service-row { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 10px 0; border-bottom: 1px solid #22252a; }.service-row > div { min-width: 0; flex: 1; }.service-row strong { font-size: 11px; font-weight: 550; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.service-row small { display: block; color: #727c8c; font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.service-logo { display: grid; place-items: center; border: 1px solid var(--line); border-radius: 6px; background: #1e2228; color: #a1afc2; width: 28px; height: 28px; font-size: 11px; font-weight: 650; flex: 0 0 auto; }.service-status { font-size: 9px; color: var(--muted); white-space: nowrap; }.service-status.online { color: var(--green); }.service-status.offline, .service-status.error { color: var(--red); }.service-status.timeout { color: var(--amber); }
|
||||
.toolbar { display: grid; grid-template-columns: minmax(200px, 1fr) repeat(3, minmax(130px, 180px)); gap: 9px; margin-bottom: 15px; }.toolbar input, .toolbar select { width: 100%; height: 38px; font-size: 12px; padding: 0 11px; }.search-field { position: relative; }.search-field .icon { position: absolute; top: 10px; left: 12px; width: 17px; height: 17px; color: var(--muted); }.search-field input { padding-left: 38px; }
|
||||
.stats { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; font-size: 11px; color: var(--muted); margin-bottom: 16px; }.stats > span:first-child { color: #cbd1da; }.scan-status { margin-left: auto; }.scan-status.running { color: var(--accent); }.scan-status.error { color: var(--red); }
|
||||
.table-panel { padding: 0; overflow: hidden; }.table-scroll { overflow-x: auto; overscroll-behavior-x: contain; }table { border-collapse: collapse; width: 100%; table-layout: fixed; text-align: left; font-size: 11px; }.col-title { width: 27%; }.col-languages { width: 18%; }th { color: #969fac; font-size: 10px; font-weight: 500; background: #181b20; padding: 12px 13px; border-bottom: 1px solid var(--line); }td { padding: 13px; border-bottom: 1px solid #25282d; vertical-align: middle; }tbody tr:last-child td { border-bottom: none; }tbody tr:hover { background: #171c21; }.title { display: inline-block; max-width: 100%; font-size: 12px; color: #d4dae4; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; }.year { display: inline-block; margin-left: 6px; font-size: 10px; }.badges { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }.badge, .state { display: inline-block; font-size: 10px; padding: 3px 6px; border: 1px solid #333943; border-radius: 5px; background: #1d2229; color: #b9c3d1; line-height: 1.3; }.badge.quality, .state.ok { color: #83d7b6; background: #122c23; border-color: #254437; }.badge.unknown, .state.warn { color: #d9b97d; background: #2a2419; border-color: #473a25; }.state.bad { color: #e49b9f; background: #2d1b20; border-color: #4b2a33; }.state.downloading { color: #c7b1f0; background: #282037; border-color: #45345f; }.state.neutral { color: var(--muted); }.badge.audio-preferred, .badge.subtitle-preferred { border-color: #318a9a; background: #15323a; color: #b8eef7; }.badge.subtitle-de { color: #83d7b6; }.badge sup { margin-left: 2px; }.error-hint { color: var(--amber); margin-left: 5px; display: inline-block; vertical-align: middle; }.error-hint .icon { width: 13px; height: 13px; }.metadata-note { font-size: 10px; margin-top: 14px; }
|
||||
.category-heading { display: flex; align-items: center; justify-content: space-between; margin: 23px 0 12px; }.category-heading h2 { font-size: 13px; }.category-heading > span { font-size: 10px; }.series { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; }.series summary { display: flex; align-items: center; gap: 10px; padding: 15px; cursor: pointer; list-style: none; }.series summary::-webkit-details-marker { display: none; }.series summary > strong { font-weight: 550; font-size: 12px; }.series summary > .muted { font-size: 10px; }.series summary > .icon { color: #697f98; width: 17px; height: 17px; }.series summary::after { content: '⌄'; margin-left: auto; color: var(--muted); }.series[open] summary::after { transform: rotate(180deg); }.series[open] summary { border-bottom: 1px solid var(--line); }.season-divider td { background: #141a20; color: #71869b; font-size: 10px; padding: 8px 13px; }.list-heading { font-size: 14px; margin: 24px 0 12px; }
|
||||
.download-table { min-width: 870px; }.download-table th:nth-child(3) { width: 17%; }.progress-wrap { display: flex; gap: 8px; align-items: center; }.progress-wrap .track { flex: 1; margin: 0; }.progress-wrap > span { font-size: 9px; }.download-size small { color: var(--muted); display: block; font-size: 10px; }
|
||||
.panel-stack { display: flex; flex-direction: column; gap: 18px; }.category-row { margin-top: 24px; }.category-row small { display: block; font-size: 10px; color: var(--muted); }.pressure-row { display: grid; grid-template-columns: 40px 45px 1fr; gap: 12px; align-items: center; font-size: 11px; margin-top: 12px; }.pressure-row .track { margin: 0; }.pressure-row strong { font-size: 10px; }.raid-disks { margin-top: 18px; display: grid; gap: 7px; }.raid-disk { padding: 11px; border: 1px solid var(--line); border-radius: 7px; display: flex; align-items: flex-start; gap: 9px; min-width: 0; }.raid-disk > div { min-width: 0; flex: 1; }.raid-disk strong { font-size: 11px; font-weight: 550; }.raid-disk small { display: block; color: var(--muted); font-size: 10px; overflow-wrap: anywhere; }.raid-disk > b { font-size: 9px; font-weight: 500; color: var(--muted); }.disk-status { width: 5px; height: 5px; border-radius: 50%; margin-top: 7px; background: var(--amber); flex: 0 0 auto; }.disk-status.active { background: var(--green); }.disk-status.failed { background: var(--red); }.raid-status.ok { color: var(--green); }.raid-status.degraded, .raid-status.rebuilding { color: var(--amber); }.raid-status.failed { color: var(--red); }.recovery-box { margin-top: 15px; padding: 12px; background: #11282e; border: 1px solid #23444d; border-radius: 7px; }
|
||||
.graphics-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 18px; }.graphics-meta-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }.graphics-meta-grid small { color: var(--muted); display: block; font-size: 10px; }.graphics-meta-grid strong { display: block; margin-top: 5px; font-size: 13px; font-weight: 500; }.graphics-details { display: grid; gap: 8px; margin-top: 20px; border-top: 1px solid var(--line); padding-top: 16px; color: var(--muted); font-size: 10px; overflow-wrap: anywhere; }.graphics-process { display: flex; gap: 20px; padding: 12px 0; border-bottom: 1px solid var(--line); font-size: 11px; flex-wrap: wrap; }.graphics-process strong { flex: 1; min-width: 150px; overflow-wrap: anywhere; font-weight: 550; }.graphics-process span { color: var(--muted); }
|
||||
.empty-state { padding: 30px 16px; text-align: center; color: var(--muted); font-size: 12px; }.empty-state > .icon { color: var(--green); display: block; margin: 0 auto 10px; }.banner, .notice { display: flex; align-items: center; gap: 9px; padding: 11px 14px; border-radius: 7px; margin-bottom: 20px; color: #efb2b5; border: 1px solid #4a2a31; background: #26191e; font-size: 12px; overflow-wrap: anywhere; }.notice { color: #abd9e5; border-color: #254a57; background: #15232b; }
|
||||
.settings-modal { width: min(440px, calc(100vw - 30px)); max-height: calc(100dvh - 40px); overflow-y: auto; border: 1px solid #3a424d; border-radius: 12px; padding: 24px; background: #191c21; color: var(--text); }.settings-modal::backdrop { background: #000a; }.settings-modal h2 { font-size: 20px; }.settings-modal p { font-size: 11px; margin-top: 5px; }.settings-modal label:not(.setting-toggle) { display: block; margin: 18px 0 7px; font-size: 12px; }.settings-modal select { width: 100%; height: 40px; padding: 0 10px; font-size: 12px; }.setting-toggle { display: flex; align-items: center; gap: 10px; margin-top: 23px; font-size: 12px; }.setting-toggle input { accent-color: var(--accent); width: 16px; height: 16px; }.settings-actions { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); margin-top: 22px; padding-top: 18px; }.settings-actions button { font-size: 12px; }
|
||||
@media (max-width: 1250px) {
|
||||
.sidebar { width: 212px; }.app-shell { margin-left: 212px; }.brand { padding: 0 20px; font-size: 21px; }.topbar { padding: 0 22px; }main { padding: 25px 22px 40px; }
|
||||
.summary-card { gap: 10px; padding: 17px 13px; }.summary-card strong { font-size: 23px; }.summary-icon { width: 34px; height: 34px; }
|
||||
.service-list { grid-template-columns: 1fr; }.dashboard-grid { gap: 14px; }.toolbar { grid-template-columns: 1fr 1fr; }.media-table { min-width: 820px; }
|
||||
}
|
||||
@media (max-width: 950px) {
|
||||
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.dashboard-grid { grid-template-columns: 1fr; }.service-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }.breadcrumb { font-size: 11px; gap: 8px; }.actions { gap: 8px; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.sidebar { transform: translateX(-100%); width: 250px; transition: transform .2s ease; z-index: 60; }.app-shell { margin-left: 0; }.menu-toggle { display: inline-grid; }.sidebar-shade { position: fixed; inset: 0; z-index: 50; border: 0; border-radius: 0; background: #0009; }.sidebar-shade:hover { background: #0009; }body.sidebar-open .sidebar { transform: none; }body.sidebar-open .sidebar-shade { display: block; }body.sidebar-open { overflow: hidden; }
|
||||
.topbar { height: 59px; padding: 0 14px; gap: 9px; }.breadcrumb { color: var(--text); }.breadcrumb > :not(strong) { display: none; }.breadcrumb { font-size: 0; }.breadcrumb strong { font-size: 12px; }.actions { gap: 7px; }.scan-control { gap: 5px; }.scan-control select { max-width: 102px; font-size: 10px; }.scan-control .primary-button { padding: 8px; }.scan-control .primary-button > span { display: none; }
|
||||
main { padding: 22px 15px 32px; }h1 { font-size: 23px; }.page-heading { gap: 10px; margin-bottom: 20px; align-items: flex-start; }.page-heading p { font-size: 11px; }.live-label { font-size: 0; margin-top: 9px; }.live-label > span:not(.status-dot) { display: none; }
|
||||
.summary-grid { gap: 9px; margin-bottom: 22px; }.summary-card { min-height: 96px; padding: 14px 11px; gap: 10px; }.summary-card strong { font-size: 22px; }.summary-card small { font-size: 9px; }.summary-card > div > span { font-size: 11px; }.summary-icon { width: 31px; height: 31px; }.summary-icon > .icon { width: 17px; height: 17px; }
|
||||
.section-title h2 { font-size: 16px; }.panel { padding: 17px; }.panel.table-panel { padding: 0; }.service-list { column-gap: 14px; }.service-row { gap: 6px; }.service-logo { width: 24px; height: 24px; }.service-row strong { font-size: 10px; }.service-status { font-size: 8px; }.system-meters { gap: 18px; }.system-details { gap: 9px; }.system-details strong { font-size: 10px; }.toolbar { gap: 7px; }.toolbar .search-field { grid-column: 1 / -1; }.toolbar select:last-child { grid-column: 1 / -1; }.stats { gap: 8px; font-size: 10px; }.scan-status { margin-left: 0; }.series summary { flex-wrap: wrap; gap: 7px; }.series summary > strong { max-width: calc(100% - 45px); overflow-wrap: anywhere; }.series summary > .muted { width: 100%; margin-left: 24px; }.series summary::after { position: absolute; right: 15px; }.series summary { position: relative; }
|
||||
.language-metrics strong { font-size: 23px; }.language-metrics small { font-size: 9px; }.graphics-cards { grid-template-columns: minmax(0, 1fr); }.graphics-meta-grid { gap: 8px; grid-template-columns: repeat(2, 1fr); }.disk-row { grid-template-columns: 58px minmax(0, 1fr) auto; gap: 8px; }
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
.service-list { grid-template-columns: 1fr; }.summary-icon { display: none; }.language-metrics { grid-template-columns: 1fr 1fr; }.scan-control select { max-width: 88px; }.topbar { gap: 6px; }.actions { gap: 5px; }.breadcrumb strong { font-size: 11px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
'use strict';
|
||||
|
||||
const config = JSON.parse(document.querySelector('#dashboardConfig').textContent);
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||
const html = value => String(value ?? '—').replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
|
||||
const icon = name => `<svg class="icon" aria-hidden="true"><use href="#icon-${name}"/></svg>`;
|
||||
const set = (id, value) => { const node = $(`#${id}`); if (node) node.textContent = value; };
|
||||
const percent = value => Math.max(0, Math.min(100, Number(value) || 0));
|
||||
const bar = (id, value) => { const node = $(`#${id}`); if (node) node.style.width = `${percent(value)}%`; };
|
||||
let language = localStorage.getItem('dashboard-language') === 'en' ? 'en' : 'de';
|
||||
const t = (de, en) => language === 'en' ? en : de;
|
||||
const number = value => Number(value || 0).toLocaleString(language === 'en' ? 'en-GB' : 'de-DE');
|
||||
const empty = message => `<div class="empty-state">${html(message)}</div>`;
|
||||
let activeView = 'dashboard';
|
||||
const snapshots = {};
|
||||
|
||||
function closeSidebar() {
|
||||
document.body.classList.remove('sidebar-open');
|
||||
$('#menuToggle').setAttribute('aria-expanded', 'false');
|
||||
$('#sidebar').inert = window.matchMedia('(max-width:700px)').matches;
|
||||
}
|
||||
|
||||
function switchView(view) {
|
||||
const button = $(`.nav button[data-view="${view}"]`);
|
||||
if (!button || !$(`#${view}`)) return;
|
||||
activeView = view;
|
||||
$$('.view').forEach(node => node.classList.toggle('active', node.id === view));
|
||||
$$('.nav button').forEach(node => {
|
||||
node.classList.toggle('active', node === button);
|
||||
if (node === button) node.setAttribute('aria-current', 'page');
|
||||
else node.removeAttribute('aria-current');
|
||||
});
|
||||
set('pageTitle', button.querySelector('[data-de]').textContent);
|
||||
sessionStorage.setItem('dashboard-active-view', view);
|
||||
if (['radarr', 'sonarr', 'missing'].includes(view)) $('#scanSource').value = view;
|
||||
closeSidebar();
|
||||
}
|
||||
|
||||
function translateLayout() {
|
||||
document.documentElement.lang = language;
|
||||
localStorage.setItem('dashboard-language', language);
|
||||
$$('[data-de][data-en]').forEach(node => { node.textContent = node.dataset[language]; });
|
||||
$$('[data-placeholder-de]').forEach(node => { node.placeholder = node.dataset[language === 'en' ? 'placeholderEn' : 'placeholderDe']; });
|
||||
set('languageToggle', language === 'en' ? 'DE' : 'EN');
|
||||
switchView(activeView);
|
||||
applyFilters('movie');
|
||||
applyFilters('series');
|
||||
updateMissingCounts();
|
||||
Object.entries(snapshots).forEach(([key, value]) => renderers[key](value));
|
||||
}
|
||||
|
||||
$$('.nav button').forEach(button => button.addEventListener('click', () => switchView(button.dataset.view)));
|
||||
$$('[data-go]').forEach(button => button.addEventListener('click', () => switchView(button.dataset.go)));
|
||||
$('#languageToggle').addEventListener('click', () => { language = language === 'de' ? 'en' : 'de'; translateLayout(); });
|
||||
$('#menuToggle').addEventListener('click', () => {
|
||||
const open = document.body.classList.toggle('sidebar-open');
|
||||
$('#sidebar').inert = !open;
|
||||
$('#menuToggle').setAttribute('aria-expanded', String(open));
|
||||
if (open) $('.nav button.active').focus();
|
||||
});
|
||||
$('#sidebarShade').addEventListener('click', () => { closeSidebar(); $('#menuToggle').focus(); });
|
||||
window.matchMedia('(max-width:700px)').addEventListener('change', closeSidebar);
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
|
||||
closeSidebar(); $('#menuToggle').focus();
|
||||
}
|
||||
if (event.key === 'Tab' && document.body.classList.contains('sidebar-open')) {
|
||||
const controls = $$('#sidebar a, #sidebar button');
|
||||
const first = controls[0], last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
}
|
||||
});
|
||||
|
||||
function seasonVisible(row) {
|
||||
return localStorage.getItem('show-season-zero') === 'true' || row.dataset.season !== '0';
|
||||
}
|
||||
|
||||
function applyFilters(prefix) {
|
||||
const search = $(`#${prefix}Search`);
|
||||
if (!search) return;
|
||||
const query = search.value.trim().toLowerCase();
|
||||
const audio = $(`#${prefix}Audio`).value, subs = $(`#${prefix}Subs`).value, state = $(`#${prefix}State`).value;
|
||||
let visible = 0;
|
||||
const matches = (row, title = '') => (!query || `${row.dataset.title} ${title}`.includes(query)) &&
|
||||
(!audio || row.dataset.audio.includes(audio)) && (!subs || row.dataset.subs.includes(subs)) &&
|
||||
(!state || row.dataset.state === state) && seasonVisible(row);
|
||||
if (prefix === 'movie') {
|
||||
$$('#movies tbody tr[data-title]').forEach(row => {
|
||||
const show = matches(row); row.classList.toggle('hidden', !show); if (show) visible++;
|
||||
});
|
||||
set('movieCount', `${number(visible)} ${t('Filme', 'movies')}`);
|
||||
} else {
|
||||
$$('#seriesList .series').forEach(series => {
|
||||
let count = 0;
|
||||
series.querySelectorAll('tr[data-title]').forEach(row => {
|
||||
const show = matches(row, series.dataset.title); row.classList.toggle('hidden', !show); if (show) count++;
|
||||
});
|
||||
series.classList.toggle('hidden', !count);
|
||||
if (count) visible++;
|
||||
updateSeasonDividers(series);
|
||||
});
|
||||
$$('#seriesList .series-category').forEach(category => category.classList.toggle('hidden', !category.querySelector('.series:not(.hidden)')));
|
||||
set('seriesCount', `${number(visible)} ${t('Serien', 'series')}`);
|
||||
}
|
||||
$(`#${prefix}NoResults`)?.classList.toggle('hidden', visible > 0);
|
||||
}
|
||||
|
||||
function updateSeasonDividers(root) {
|
||||
root.querySelectorAll('.season-divider').forEach(divider => {
|
||||
let next = divider.nextElementSibling, count = 0;
|
||||
while (next && !next.classList.contains('season-divider')) {
|
||||
if (!next.classList.contains('hidden')) count++;
|
||||
next = next.nextElementSibling;
|
||||
}
|
||||
divider.classList.toggle('hidden', count === 0);
|
||||
divider.firstElementChild.textContent = `${t('Staffel', 'Season')} ${divider.dataset.season}`;
|
||||
});
|
||||
}
|
||||
|
||||
$$('.series tbody').forEach(body => {
|
||||
let season = null;
|
||||
[...body.querySelectorAll('tr[data-season]')].forEach(row => {
|
||||
if (row.dataset.season === season) return;
|
||||
season = row.dataset.season;
|
||||
const divider = document.createElement('tr');
|
||||
divider.className = 'season-divider'; divider.dataset.season = season;
|
||||
divider.innerHTML = `<td colspan="7">${t('Staffel', 'Season')} ${html(season)}</td>`;
|
||||
row.before(divider);
|
||||
});
|
||||
});
|
||||
|
||||
$$('[data-filter] input, [data-filter] select').forEach(input => {
|
||||
const saved = sessionStorage.getItem(`filter-${input.id}`);
|
||||
if (saved !== null) input.value = saved;
|
||||
else if (input.id.endsWith('Audio')) input.value = localStorage.getItem('preferred-audio') || '';
|
||||
else if (input.id.endsWith('Subs')) input.value = localStorage.getItem('preferred-subs') || '';
|
||||
input.addEventListener(input.type === 'search' ? 'input' : 'change', () => {
|
||||
sessionStorage.setItem(`filter-${input.id}`, input.value);
|
||||
applyFilters(input.closest('[data-filter]').dataset.filter);
|
||||
});
|
||||
});
|
||||
|
||||
function updateMissingCounts() {
|
||||
$$('#missing .missing-series').forEach(series => {
|
||||
let count = 0;
|
||||
series.querySelectorAll('tr[data-title]').forEach(row => {
|
||||
const show = seasonVisible(row); row.classList.toggle('hidden', !show); if (show) count++;
|
||||
});
|
||||
series.classList.toggle('hidden', count === 0);
|
||||
updateSeasonDividers(series);
|
||||
});
|
||||
const count = $$('#missing tr[data-title]:not(.hidden)').length;
|
||||
set('missingNavCount', number(count)); set('dashboardMissing', number(count));
|
||||
set('missingCount', `${number(count)} ${t('Einträge ohne Deutsch', 'entries without German')}`);
|
||||
set('seasonZeroHint', localStorage.getItem('show-season-zero') === 'true' ? t('Staffel 0 eingeblendet', 'Season 0 included') : t('Staffel 0 ausgeblendet', 'Season 0 hidden'));
|
||||
$('#missingEmpty').classList.toggle('hidden', count > 0);
|
||||
}
|
||||
|
||||
function highlightPreferences() {
|
||||
$$('.media-table tr[data-title]').forEach(row => {
|
||||
[[3, 'preferred-audio', 'audio-preferred'], [4, 'preferred-subs', 'subtitle-preferred']].forEach(([column, key, className]) => {
|
||||
const preference = localStorage.getItem(key);
|
||||
const badges = [...row.cells[column].querySelectorAll('.badge')];
|
||||
badges.forEach(badge => {
|
||||
badge.classList.toggle(className, badge.dataset.language === preference);
|
||||
if (column === 4) badge.classList.toggle('subtitle-de', badge.dataset.language === 'Deutsch');
|
||||
});
|
||||
const preferred = badges.find(badge => badge.dataset.language === preference);
|
||||
if (preferred) preferred.parentElement.prepend(preferred);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$('#settingsOpen').addEventListener('click', () => {
|
||||
closeSidebar();
|
||||
$('#preferredAudio').value = localStorage.getItem('preferred-audio') || '';
|
||||
$('#preferredSubs').value = localStorage.getItem('preferred-subs') || '';
|
||||
$('#showSeasonZero').checked = localStorage.getItem('show-season-zero') === 'true';
|
||||
$('#settingsModal').showModal();
|
||||
});
|
||||
$('#settingsSave').addEventListener('click', () => {
|
||||
localStorage.setItem('preferred-audio', $('#preferredAudio').value);
|
||||
localStorage.setItem('preferred-subs', $('#preferredSubs').value);
|
||||
localStorage.setItem('show-season-zero', String($('#showSeasonZero').checked));
|
||||
for (const prefix of ['movie', 'series']) {
|
||||
for (const [suffix, value] of [['Audio', $('#preferredAudio').value], ['Subs', $('#preferredSubs').value]]) {
|
||||
const input = $(`#${prefix}${suffix}`);
|
||||
if (input) { input.value = value; sessionStorage.setItem(`filter-${input.id}`, value); }
|
||||
}
|
||||
applyFilters(prefix);
|
||||
}
|
||||
highlightPreferences(); updateMissingCounts(); $('#settingsModal').close();
|
||||
});
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 25000);
|
||||
try {
|
||||
const response = await fetch(path, {cache: 'no-store', ...options, signal: controller.signal});
|
||||
const data = await response.json();
|
||||
if (!response.ok && !data.error && !data.storage?.error) throw new Error(`HTTP ${response.status}`);
|
||||
return data;
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
function duration(seconds) {
|
||||
const minutes = Math.floor(Number(seconds || 0) / 60), days = Math.floor(minutes / 1440), hours = Math.floor(minutes % 1440 / 60);
|
||||
return `${days ? `${days}${t(' T', 'd')} ` : ''}${hours}${t(' Std.', 'h')} ${minutes % 60}${t(' Min.', 'm')}`;
|
||||
}
|
||||
|
||||
function renderSystem(data) {
|
||||
if (data.error) { set('systemUpdated', data.error); return; }
|
||||
const cpu = percent(data.cpu_percent), memory = data.memory || {};
|
||||
set('cpuValue', `${cpu.toFixed(1)}%`); set('ramValue', `${percent(memory.percent).toFixed(1)}%`);
|
||||
set('ramDetail', `${memory.used || '—'} / ${memory.total || '—'}`);
|
||||
bar('cpuBar', cpu); bar('ramBar', memory.percent);
|
||||
set('systemUpdated', `${t('Aktualisiert', 'Updated')} ${new Date().toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-GB')}`);
|
||||
$('#systemDetails').innerHTML = `<div><small>Load Average</small><strong>${(data.load || []).map(value => Number(value).toFixed(2)).join(' · ') || '—'}</strong></div><div><small>Uptime</small><strong>${html(duration(data.uptime_seconds))}</strong></div><div><small>${t('Prozesse', 'Processes')}</small><strong>${number(data.processes)}</strong></div>`;
|
||||
$('#diskList').innerHTML = (data.disks || []).map(disk => `<div class="disk-row"><div>${html(disk.label)}<small>${html(disk.path)}</small></div><div class="track"><span style="width:${percent(disk.percent)}%"></span></div><div class="disk-values">${html(disk.used)} / ${html(disk.total)}<small>${percent(disk.percent).toFixed(1)}%</small></div></div>`).join('') || empty(t('Keine Laufwerke erkannt.', 'No disks detected.'));
|
||||
}
|
||||
|
||||
function renderServices(data) {
|
||||
set('serviceSummary', `${data.online || 0} / ${data.total || 0} online`);
|
||||
const labels = {online: t('Aktiv', 'Online'), offline: 'Offline', timeout: 'Timeout', checking: t('Prüfe …', 'Checking …'), error: t('Fehler', 'Error')};
|
||||
$('#serviceList').innerHTML = (data.services || []).map(service => `<div class="service-row"><span class="service-logo">${html(service.name.slice(0, 1))}</span><div><strong>${html(service.name)}</strong><small title="${html(service.detail)}">${html(service.detail || '—')}</small></div><span class="service-status ${html(service.status)}">${html(labels[service.status] || service.label)}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function downloadRow(item) {
|
||||
const pct = percent(item.percentage);
|
||||
const languages = (item.languages || []).map(lang => `<span class="badge">${html(lang.flag)} ${html(lang.name)}</span>`).join('') || `<span class="muted">${t('Nicht erkannt', 'Not detected')}</span>`;
|
||||
return `<tr><td><span class="title" title="${html(item.title)}">${html(item.title)}</span></td><td><span class="state ${item.completed ? 'ok' : 'downloading'}">${html(item.status)}</span></td><td><div class="progress-wrap"><div class="track"><span style="width:${pct}%"></span></div><span>${pct.toFixed(0)}%</span></div></td><td class="download-size">${html(item.size)}<small>${html(item.remaining)}</small></td><td>${html(item.timeleft)}</td><td>${html(item.source)}</td><td><div class="badges">${languages}</div></td></tr>`;
|
||||
}
|
||||
|
||||
function renderDownloads(data) {
|
||||
const count = Number(data.active_count) || 0;
|
||||
set('dashboardDownloads', data.error ? '—' : number(count));
|
||||
set('dashboardRemaining', data.error ? t('Nicht erreichbar', 'Unavailable') : `${t('Noch', 'Remaining')} ${data.remaining || '0 B'}`);
|
||||
set('downloadNavCount', number(count)); $('#downloadNavCount')?.classList.toggle('hidden', count === 0);
|
||||
set('sabRemaining', data.remaining || '—'); set('sabActive', number(count)); set('sabSpeed', data.speed || '—'); set('sabHistory', number(data.history_count));
|
||||
set('sabQueueStatus', data.error ? t('Nicht erreichbar', 'Unavailable') : data.paused ? t('Pausiert', 'Paused') : `SABnzbd · ${data.queue_status || 'Idle'}`);
|
||||
$('#downloadError').classList.toggle('hidden', !data.error); set('downloadError', data.error || '');
|
||||
const items = data.items || [];
|
||||
const active = items.filter(item => !item.completed && String(item.status).toLowerCase() === 'downloading');
|
||||
const others = items.filter(item => !active.includes(item));
|
||||
for (const [id, list] of [['activeDownloads', active], ['otherDownloads', others]]) {
|
||||
set(`${id}Count`, number(list.length));
|
||||
$(`#${id}Body`).innerHTML = list.length ? list.map(downloadRow).join('') : `<tr><td colspan="7" class="empty-state">${t('Keine Einträge.', 'No entries.')}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDatastore(data) {
|
||||
const storage = data.storage || {}, raid = data.raid || {};
|
||||
for (const [id, key] of [['Total', 'total'], ['Used', 'used'], ['Free', 'free']]) set(`datastore${id}`, storage[key] || '—');
|
||||
set('datastorePercent', storage.percent == null ? '—' : `${storage.percent}%`);
|
||||
set('datastorePath', storage.path || '—'); set('datastoreCapacityLabel', storage.total ? `${storage.used} / ${storage.total}` : storage.error || '—');
|
||||
bar('datastoreCapacityBar', storage.percent);
|
||||
set('datastoreUpdated', storage.scanned_at ? `${t('Speicher-Scan', 'Storage scan')} ${new Date(storage.scanned_at * 1000).toLocaleTimeString()}` : t('Nicht verfügbar', 'Unavailable'));
|
||||
$('#storageWarning').classList.toggle('hidden', !storage.error && !storage.warning);
|
||||
set('storageWarning', storage.error || storage.warning || '');
|
||||
$('#datastoreCategories').innerHTML = (storage.categories || []).map(category => `<div class="category-row"><div class="metric-line"><span>${html(category.label)}</span><strong>${html(category.size || '0 B')}</strong></div><div class="track"><span style="width:${percent(category.percent)}%"></span></div><small>${Number(category.percent || 0).toFixed(1)}% ${t('vom belegten Speicher', 'of used storage')} · ${number(category.files)} ${t('Dateien', 'files')}</small></div>`).join('') || empty(t('Keine Kategorien erkannt.', 'No categories detected.'));
|
||||
const io = data.io_pressure || {}, some = Number(io.some?.avg10) || 0, full = Number(io.full?.avg10) || 0;
|
||||
const pressure = Math.max(some, full);
|
||||
set('ioPressureLabel', io.available ? pressure >= 20 ? t('Hoch', 'High') : pressure >= 5 ? t('Beobachten', 'Watch') : t('Normal', 'Normal') : t('Nicht verfügbar', 'Unavailable'));
|
||||
for (const [key, value] of [['Some', some], ['Full', full]]) { set(`ioPressure${key}`, io.available ? `${value.toFixed(1)}%` : '—'); bar(`ioPressure${key}Bar`, value); }
|
||||
set('raidDevice', raid.device || '—'); set('raidStatusBadge', raid.label || 'UNKNOWN');
|
||||
$('#raidStatusBadge').className = `badge raid-status ${raid.status || 'unknown'}`;
|
||||
$('#raidWarning').classList.toggle('hidden', !raid.error && !raid.warning); set('raidWarning', raid.error || raid.warning || '');
|
||||
$('#raidSummary').innerHTML = `<div><small>Level</small><strong>${html(raid.level)}</strong></div><div><small>${t('Aktive Laufwerke', 'Active drives')}</small><strong>${html(raid.active_devices)} / ${html(raid.raid_devices)}</strong></div><div><small>${t('Fehlend', 'Missing')}</small><strong>${html(raid.failed_devices ?? '—')}</strong></div>`;
|
||||
const recovery = raid.recovery || {};
|
||||
$('#raidRecovery').classList.toggle('hidden', !recovery.active); set('raidRecoveryPercent', `${Number(recovery.percent || 0).toFixed(1)}%`);
|
||||
set('raidRecoverySpeed', recovery.speed || '—'); set('raidRecoveryFinish', recovery.finish || '—'); bar('raidRecoveryBar', recovery.percent);
|
||||
$('#raidDisks').innerHTML = (raid.devices || []).map(disk => `<div class="raid-disk"><span class="disk-status ${html(disk.status)}"></span><div><strong>${html(disk.device)}</strong><small>Slot ${html(disk.slot)} · ${html(disk.model)}</small><small>${html(disk.size)} · Serial: ${html(disk.serial)}</small><small>SMART: ${html(disk.smart || t('Nicht aktiviert', 'Not enabled'))} · ${html(disk.temperature || '—')}</small></div><b>${html(disk.status)}</b></div>`).join('') || empty(t('Keine RAID-Mitglieder erkannt.', 'No RAID members detected.'));
|
||||
}
|
||||
|
||||
const gpuValue = (value, unit = '') => value == null || value === '' ? '—' : `${html(value)}${unit}`;
|
||||
function renderGraphics(data) {
|
||||
const gpus = data.gpus || [];
|
||||
set('graphicsStatus', data.available ? `${gpus.length} GPU${gpus.length === 1 ? '' : 's'}` : t('Nicht verfügbar', 'Unavailable'));
|
||||
const average = key => {
|
||||
const values = gpus.map(gpu => gpu[key]).filter(value => value != null && Number.isFinite(Number(value))).map(Number);
|
||||
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : null;
|
||||
};
|
||||
const used = gpus.reduce((sum, gpu) => sum + (Number(gpu['memory.used']) || 0), 0), total = gpus.reduce((sum, gpu) => sum + (Number(gpu['memory.total']) || 0), 0);
|
||||
const metrics = [['GPUs', gpus.length], [t('GPU-Auslastung', 'GPU usage'), gpuValue(average('utilization.gpu'), '%')], ['VRAM', total ? `${(used / 1024).toFixed(1)} / ${(total / 1024).toFixed(1)} GB` : '—'], [t('Temperatur', 'Temperature'), gpuValue(average('temperature.gpu'), ' °C')]];
|
||||
$('#graphicsSummary').innerHTML = metrics.map(([label, value]) => `<article class="summary-card"><span class="summary-icon purple">${icon('chip')}</span><div><strong>${value}</strong><span>${html(label)}</span></div></article>`).join('');
|
||||
$('#graphicsCards').innerHTML = gpus.length ? gpus.map(gpu => {
|
||||
const used = Number(gpu['memory.used']) || 0, total = Number(gpu['memory.total']) || 0;
|
||||
return `<article class="panel"><div class="panel-heading"><div><small class="muted">GPU ${html(gpu.index)}</small><h2>${html(gpu.name)}</h2></div><span class="badge">${gpuValue(gpu['temperature.gpu'], ' °C')}</span></div><div class="graphics-meta-grid"><div><small>${t('GPU-Last', 'GPU load')}</small><strong>${gpuValue(gpu['utilization.gpu'], '%')}</strong></div><div><small>${t('Memory-Last', 'Memory load')}</small><strong>${gpuValue(gpu['utilization.memory'], '%')}</strong></div><div><small>${t('Leistung', 'Power')}</small><strong>${gpuValue(gpu['power.draw'], ' W')} / ${gpuValue(gpu['power.limit'], ' W')}</strong></div><div><small>P-State</small><strong>${html(gpu.pstate)}</strong></div></div><div class="coverage-row" style="margin-top:22px"><div class="metric-line"><span>VRAM</span><strong>${total ? `${(used / 1024).toFixed(1)} / ${(total / 1024).toFixed(1)} GB` : '—'}</strong></div><div class="track purple-track"><span style="width:${total ? percent(used / total * 100) : 0}%"></span></div></div><div class="graphics-details"><span>${t('Treiber', 'Driver')}: ${html(gpu.driver_version)} · PCI: ${html(gpu['pci.bus_id'])}</span><span>UUID: ${html(gpu.uuid)}</span><span>Serial: ${html(gpu.serial)}</span><span>Clocks: ${gpuValue(gpu['clocks.current.graphics'], ' MHz')} · Memory: ${gpuValue(gpu['clocks.current.memory'], ' MHz')}</span><span>Fan: ${gpuValue(gpu['fan.speed'], '%')} · Compute: ${html(gpu.compute_mode)}</span></div></article>`;
|
||||
}).join('') : `<div class="panel">${empty(data.error || t('Keine NVIDIA-GPU erkannt.', 'No NVIDIA GPU detected.'))}</div>`;
|
||||
$('#graphicsProcesses').innerHTML = (data.processes || []).map(process => `<div class="graphics-process"><strong>${html(process.name)}</strong><span>PID ${html(process.pid)}</span><span>${html(process.memory)}</span></div>`).join('') || empty(t('Keine GPU-Prozesse erkannt.', 'No GPU processes detected.'));
|
||||
}
|
||||
|
||||
let scanRunning = false;
|
||||
function renderScanStatus(data) {
|
||||
scanRunning = false;
|
||||
for (const source of ['radarr', 'sonarr', 'missing']) {
|
||||
const job = data[source] || {};
|
||||
const node = $(`#${source}Status`);
|
||||
if (job.state === 'running') scanRunning = true;
|
||||
if (!node) continue;
|
||||
node.className = `scan-status ${job.state || ''}`;
|
||||
node.textContent = job.state === 'running' ? `${t('Scan läuft', 'Scanning')} · ${job.done}/${job.total || '?'} · ${job.total ? Math.round(job.done / job.total * 100) : 0}%` :
|
||||
job.state === 'done' ? t('Scan abgeschlossen', 'Scan complete') : job.state === 'error' ? `${t('Scanfehler', 'Scan error')}: ${job.error}` : t('Scan bereit', 'Ready to scan');
|
||||
}
|
||||
$('#rescan').disabled = scanRunning;
|
||||
const label = $('#rescan span');
|
||||
label.textContent = scanRunning ? t('Scan läuft …', 'Scanning …') : t('Neu scannen', 'Rescan');
|
||||
}
|
||||
|
||||
$('#rescan').addEventListener('click', async () => {
|
||||
$('#rescan').disabled = true; $('#actionError').classList.add('hidden');
|
||||
try {
|
||||
const data = await api(`/api/rescan?source=${encodeURIComponent($('#scanSource').value)}`, {method: 'POST'});
|
||||
if (!data.ok) throw new Error(data.error || t('Scan konnte nicht gestartet werden.', 'Could not start scan.'));
|
||||
renderScanStatus(await api('/api/scan-status'));
|
||||
} catch (error) {
|
||||
set('actionError', `${t('Scan fehlgeschlagen', 'Scan failed')}: ${error.message}`); $('#actionError').classList.remove('hidden');
|
||||
} finally { $('#rescan').disabled = scanRunning; }
|
||||
});
|
||||
|
||||
const renderers = {system: renderSystem, services: renderServices, downloads: renderDownloads, datastore: renderDatastore, graphics: renderGraphics, scans: renderScanStatus};
|
||||
function monitor(key, path, interval, onError) {
|
||||
const update = async () => {
|
||||
if (!document.hidden) {
|
||||
try { const data = await api(path); snapshots[key] = data; renderers[key](data); }
|
||||
catch (error) { onError?.(error); }
|
||||
}
|
||||
setTimeout(update, interval);
|
||||
};
|
||||
update();
|
||||
}
|
||||
|
||||
async function pollLibrary() {
|
||||
if (!document.hidden && !$('#settingsModal').open) {
|
||||
try {
|
||||
const data = await api('/api/library-status');
|
||||
if (data.ready && (!config.libraryReady || Number(data.updated_at) > config.libraryUpdatedAt)) {
|
||||
sessionStorage.setItem('dashboard-active-view', activeView);
|
||||
location.reload(); return;
|
||||
}
|
||||
} catch (error) { /* Keep the current snapshot during a connection outage. */ }
|
||||
}
|
||||
setTimeout(pollLibrary, 5000);
|
||||
}
|
||||
|
||||
switchView(sessionStorage.getItem('dashboard-active-view') || 'dashboard');
|
||||
translateLayout(); highlightPreferences();
|
||||
monitor('system', '/api/system', 5000, () => set('systemUpdated', t('Nicht erreichbar', 'Unavailable')));
|
||||
monitor('services', '/api/services', 30000, () => set('serviceSummary', t('Nicht erreichbar', 'Unavailable')));
|
||||
monitor('datastore', '/api/datastore', 5000, () => renderDatastore({storage: {error: t('Datastore nicht erreichbar.', 'Datastore unavailable.')}, raid: {error: t('RAID-Status nicht verfügbar.', 'RAID status unavailable.')}}));
|
||||
monitor('graphics', '/api/graphics', 5000, () => renderGraphics({error: t('GPU-Status nicht verfügbar.', 'GPU status unavailable.')}));
|
||||
monitor('scans', '/api/scan-status', 1500);
|
||||
if (config.sabEnabled) monitor('downloads', '/api/downloads', 4000, () => renderDownloads({error: t('SABnzbd nicht erreichbar.', 'SABnzbd unavailable.')}));
|
||||
pollLibrary();
|
||||
if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js?v=8', {scope: '/'}).catch(() => {}));
|
||||
@@ -9,8 +9,8 @@
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
"prefer_related_applications": false,
|
||||
"background_color": "#151619",
|
||||
"theme_color": "#1f252d",
|
||||
"background_color": "#0c0d0f",
|
||||
"theme_color": "#101113",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{"src": "/static/media-mark-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
const CACHE_NAME = 'media-max-shell-v7';
|
||||
const SHELL = ['/static/media-mark.svg', '/static/media-mark-192.png', '/static/media-mark-512.png', '/static/manifest.json'];
|
||||
const CACHE_NAME = 'media-max-shell-v8';
|
||||
const SHELL = ['/static/media-mark.svg', '/static/media-mark-192.png', '/static/media-mark-512.png', '/static/manifest.json', '/static/dashboard.css?v=8', '/static/dashboard.js?v=8'];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL)));
|
||||
|
||||
+149
-253
@@ -1,254 +1,150 @@
|
||||
{% macro icon(name, class='') -%}
|
||||
<svg class="icon {{ class }}" aria-hidden="true"><use href="#icon-{{ name }}"/></svg>
|
||||
{%- endmacro %}
|
||||
{% macro tr(de, en) -%}<span data-de="{{ de }}" data-en="{{ en }}">{{ de }}</span>{%- endmacro %}
|
||||
{% macro languages(items) -%}
|
||||
<div class="badges">{% for l in items %}<span class="badge {{ 'unknown' if l.code == 'und' else '' }}" data-language="{{ l.name }}" title="{{ 'Quelle: Radarr/Sonarr-Dateimetadaten (nicht direkt bestätigt)' if l.source == 'arr' else 'Quelle: Mediendatei / Spurtitel' }}">{{ l.flag }} {{ l.name }}{% if l.source == 'arr' %}<sup>*</sup>{% endif %}</span>{% else %}<span class="muted">—</span>{% endfor %}</div>
|
||||
{%- endmacro %}
|
||||
{% macro media_table(items, id='', episode=false, review=false) -%}
|
||||
<div class="table-scroll"><table class="media-table" {% if id %}id="{{ id }}"{% endif %}>
|
||||
<thead><tr><th class="col-title">{{ tr('Episode' if episode else 'Film', 'Episode' if episode else 'Movie') }}</th><th>{{ tr('Qualität', 'Quality') }}</th><th>{{ tr('Status', 'Status') }}</th><th class="col-languages">Audio</th><th class="col-languages">{{ tr('Untertitel', 'Subtitles') }}</th><th>{{ tr('Video', 'Video') }}</th><th>{{ tr('Größe', 'Size') }}</th></tr></thead>
|
||||
<tbody>{% for r in items %}
|
||||
<tr data-title="{{ (r.title ~ ' ' ~ (r.year or ''))|lower }}" data-audio="{{ r.audio_languages|map(attribute='name')|join(',') }}" data-subs="{{ r.subtitle_languages|map(attribute='name')|join(',') }}" data-state="{{ r.state.label }}" data-season="{{ r.season if episode else '' }}">
|
||||
<td><span class="title" title="{{ r.title }}">{{ r.title }}</span>{% if r.year %}<span class="year">{{ r.year }}</span>{% endif %}{% if r.error %}<span class="error-hint" title="{{ r.error }}">{{ icon('alert') }}</span>{% endif %}</td>
|
||||
<td><span class="badge {{ 'quality' if not r.missing else '' }}">{{ r.quality }}</span></td>
|
||||
<td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td>
|
||||
<td>{{ languages(r.audio_languages) }}</td><td>{{ languages(r.subtitle_languages) }}</td><td class="muted">{{ r.video_codec }}</td><td class="nowrap">{{ r.size }}</td>
|
||||
</tr>{% else %}<tr class="empty-row"><td colspan="7" class="empty-state">{{ tr('Keine Einträge vorhanden.', 'No entries available.') }}</td></tr>{% endfor %}</tbody>
|
||||
</table></div>
|
||||
{%- endmacro %}
|
||||
{% macro filters(prefix, series=false) -%}
|
||||
<div class="toolbar" data-filter="{{ prefix }}">
|
||||
<label class="search-field">{{ icon('search') }}<input type="search" id="{{ prefix }}Search" aria-label="{{ 'Serie' if series else 'Film' }} suchen" data-placeholder-de="{{ 'Serie' if series else 'Film' }} suchen …" data-placeholder-en="Search {{ 'series' if series else 'movies' }} …" placeholder="{{ 'Serie' if series else 'Film' }} suchen …"></label>
|
||||
<select id="{{ prefix }}Audio" aria-label="Audio-Sprache"><option value="" data-de="Alle Audio-Sprachen" data-en="All audio languages">Alle Audio-Sprachen</option>{% for name in language_names %}<option>{{ name }}</option>{% endfor %}</select>
|
||||
<select id="{{ prefix }}Subs" aria-label="Untertitel-Sprache"><option value="" data-de="Alle Untertitel" data-en="All subtitles">Alle Untertitel</option>{% for name in language_names %}<option>{{ name }}</option>{% endfor %}</select>
|
||||
<select id="{{ prefix }}State" aria-label="Status"><option value="" data-de="Alle Status" data-en="All statuses">Alle Status</option>{% for name in ['DE + EN', 'Deutsch', 'English only', 'Andere', 'Sprache prüfen', 'Scan ausstehend', 'Fehler', 'Downloading'] %}<option>{{ name }}</option>{% endfor %}</select>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
{% macro nav_button(view, symbol, de, en, count_id='') -%}
|
||||
<button type="button" data-view="{{ view }}" {{ 'class=active' if view == 'dashboard' else '' }}>{{ icon(symbol) }}{{ tr(de, en) }}{% if count_id %}<span id="{{ count_id }}" class="nav-count">0</span>{% endif %}</button>
|
||||
{%- endmacro %}
|
||||
<!doctype html>
|
||||
<style>
|
||||
@media(max-width:700px){
|
||||
html{scroll-behavior:auto!important}
|
||||
header{backdrop-filter:none!important;box-shadow:0 2px 8px rgba(0,0,0,.22)!important}
|
||||
.dashboard-hero,.overview-card,.insight-card,.system-panel,.service-panel,.datastore-panel,.storage-card,.download-card,.download-section,.tablewrap,.series-category,.missing-table{backdrop-filter:none!important;box-shadow:none!important}
|
||||
tbody tr,.series-category>.series,.missing-table .missing-series{transform:none!important;animation:none!important}
|
||||
tbody tr:hover,.series-category>.series:hover{transform:none!important}
|
||||
.series table tbody tr:not(.season-divider){animation:none!important}
|
||||
.tablewrap{contain:layout paint;overscroll-behavior-x:contain}
|
||||
.view{contain:layout style}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
#ioPressurePanel{max-width:none;margin:0;padding:14px}.io-pressure-bars{display:grid;gap:10px}.io-pressure-bars>div{display:grid;grid-template-columns:42px 48px 1fr;align-items:center;gap:8px;color:#9da5b2;font-size:11px}.io-pressure-bars b{color:#e8eef5;text-align:right}.io-pressure-bars .datastore-progress{height:8px}.datastore-grid{align-items:stretch}.datastore-grid>.datastore-panel>.datastore-capacity{margin:0 0 12px;padding:14px}.datastore-right-stack{display:flex;flex-direction:column;gap:12px;min-height:100%}.datastore-right-stack .raid-panel{flex:1;margin:0}.datastore-right-stack #ioPressurePanel{flex:0 0 auto}@media(max-width:900px){ #ioPressurePanel{max-width:none}.datastore-right-stack{min-height:0}}
|
||||
</style>
|
||||
<style>
|
||||
@media(max-width:700px){.nav.legacy-mobile-nav,.nav.legacy-mobile-nav.mobile-drawer-open{display:none!important;visibility:hidden!important;pointer-events:none!important}body.mobile-nav-open .mobile-nav-backdrop{display:none!important}.mobile-drawer-clone:not(.open),.mobile-drawer-shade:not(.open){pointer-events:none!important}}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded',()=>{const root=document.querySelector('#datastore');if(!root)return;const grid=root.querySelector('.datastore-grid');if(grid&&!document.querySelector('#ioPressurePanel')){const panel=document.createElement('section');panel.id='ioPressurePanel';panel.className='datastore-panel io-pressure-panel';panel.innerHTML='<div class="datastore-panel-head"><div><h3>I/O-Auslastung</h3><p>Festplatten-Druck aus Linux PSI</p></div><strong id="ioPressureLabel">Wird geladen …</strong></div><div class="io-pressure-bars"><div><span>Some</span><b id="ioPressureSome">—</b><div class="datastore-progress"><span id="ioPressureSomeBar"></span></div></div><div><span>Full</span><b id="ioPressureFull">—</b><div class="datastore-progress"><span id="ioPressureFullBar"></span></div></div></div>';grid.before(panel)}const pressure=async()=>{try{const data=await (await fetch('/api/datastore')).json();const io=data.io_pressure||{};const some=Number(io.some?.avg10)||0,full=Number(io.full?.avg10)||0;const level=Math.max(some,full),label=level>=20?'Hoch':level>=5?'Beobachten':'Normal';const set=(id,value)=>{const node=document.querySelector(id);if(node)node.textContent=value};set('#ioPressureLabel',io.available?`${label} · ${level.toFixed(1)}%`:'Nicht verfügbar');set('#ioPressureSome',`${some.toFixed(1)}%`);set('#ioPressureFull',`${full.toFixed(1)}%`);const a=document.querySelector('#ioPressureSomeBar'),b=document.querySelector('#ioPressureFullBar');if(a)a.style.width=`${Math.min(100,some)}%`;if(b)b.style.width=`${Math.min(100,full)}%`}catch(error){}};pressure();setInterval(pressure,5000);const disks=document.querySelector('#raidDisks');if(disks){const observer=new MutationObserver(()=>{if([...disks.querySelectorAll('.raid-disk')].some(row=>!row.querySelector('.raid-temperature')))setTimeout(pressure,0)});observer.observe(disks,{childList:true,subtree:true})}});
|
||||
</script>
|
||||
<style>
|
||||
.graphics-live{display:flex;flex-direction:column;align-items:center;gap:2px;text-align:center}.graphics-live small{font-size:9px;color:#a99ad0;letter-spacing:.02em}
|
||||
</style>
|
||||
<script>
|
||||
(function(){function init(){const status=document.querySelector('#graphicsStatus');if(!status)return;let seconds=5;function ensureRefreshLabel(){let label=document.querySelector('#graphicsRefreshStatus');if(!label){label=document.createElement('small');label.id='graphicsRefreshStatus';status.appendChild(label)}label.textContent=seconds>0?'Auto-Update in '+seconds+'s':'Aktualisiere …'}ensureRefreshLabel();setInterval(function(){seconds=seconds<=0?5:seconds-1;ensureRefreshLabel()},1000);setInterval(function(){seconds=5},5000)}if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init()})();
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded',()=>{const root=document.querySelector('#datastore'),grid=root?.querySelector('.datastore-grid'),capacity=root?.querySelector('.datastore-capacity'),pressure=root?.querySelector('#ioPressurePanel');if(!grid||!capacity||!pressure)return;const categories=grid.children[0],raid=grid.children[1];if(categories&&!categories.contains(capacity))categories.prepend(capacity);if(raid&&!raid.contains(pressure))raid.prepend(pressure)});
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded',()=>{const grid=document.querySelector('#datastore .datastore-grid'),raid=document.querySelector('#datastore .raid-panel'),pressure=document.querySelector('#ioPressurePanel');if(!grid||!raid||!pressure||grid.querySelector('.datastore-right-stack'))return;const stack=document.createElement('div');stack.className='datastore-right-stack';grid.replaceChild(stack,raid);stack.append(pressure,raid)});
|
||||
</script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js?v=7', {scope: '/'}).catch(function () {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<style>@media(max-width:700px){header .nav,main>.nav{display:none!important}#movies td:nth-child(4) .badge:nth-child(n+2),#movies td:nth-child(5) .badge:nth-child(n+2),#sonarr td:nth-child(4) .badge:nth-child(n+2),#sonarr td:nth-child(5) .badge:nth-child(n+2),#missing td:nth-child(4) .badge:nth-child(n+2),#missing td:nth-child(5) .badge:nth-child(n+2){display:none!important}.year{display:none!important}.title{max-width:100%!important}}</style>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{const applyPreferred=()=>{const audio=localStorage.getItem('preferred-audio')||'',subs=localStorage.getItem('preferred-subs')||'';document.querySelectorAll('#movies tbody tr,#sonarr tbody tr,#missing tbody tr').forEach(row=>{[[4,audio,'audio-preferred'],[5,subs,'subtitle-preferred']].forEach(([column,preference,className])=>{const badges=[...row.querySelectorAll(`td:nth-child(${column}) .badge`)];if(!badges.length)return;badges.forEach(badge=>badge.classList.remove(className));const preferred=preference?badges.find(badge=>badge.textContent.includes(preference)):null;if(preferred){preferred.classList.add(className);preferred.parentElement.prepend(preferred)}})})};applyPreferred();setInterval(applyPreferred,1000)});</script>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{const nav=document.querySelector('.nav');if(nav)nav.classList.add('legacy-mobile-nav');const closeAll=()=>{document.body.classList.remove('mobile-nav-open');document.querySelectorAll('.mobile-drawer-clone,.mobile-drawer-shade,.mobile-nav-backdrop').forEach(node=>node.classList.remove('open'));document.querySelectorAll('.nav').forEach(node=>node.classList.remove('mobile-drawer-open'))};document.addEventListener('click',event=>{if(!window.matchMedia('(max-width:700px)').matches||!document.body.classList.contains('mobile-nav-open'))return;if(!event.target.closest('.mobile-drawer-clone,.mobile-nav,.brand'))closeAll()});document.addEventListener('keydown',event=>{if(event.key==='Escape')closeAll()});window.addEventListener('resize',()=>{if(!window.matchMedia('(max-width:700px)').matches)closeAll()})});</script>
|
||||
<style>@media(max-width:700px){.legacy-mobile-nav{display:none!important}.mobile-nav-backdrop,.mobile-drawer-shade{pointer-events:auto!important}}</style>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{const style=document.createElement('style');style.textContent='@media(max-width:700px){ #movies,#sonarr table,#missing table{min-width:0!important;width:100%!important;table-layout:fixed!important}.tablewrap{overflow-x:hidden!important}.badges{max-width:100%!important;display:flex!important;flex-wrap:nowrap!important;overflow:hidden!important;max-height:22px!important}.badge{flex:0 1 auto!important;max-width:100%!important;overflow:hidden!important;text-overflow:ellipsis!important;font-size:9px!important;padding:2px 4px!important}.title{max-width:100%!important;overflow:hidden!important;text-overflow:ellipsis!important;white-space:nowrap!important}#movies th:nth-child(1),#sonarr th:nth-child(1),#missing th:nth-child(1){width:42%!important}#movies th:nth-child(2),#sonarr th:nth-child(2),#missing th:nth-child(2){width:19%!important}#movies th:nth-child(4),#movies th:nth-child(5),#sonarr th:nth-child(4),#sonarr th:nth-child(5),#missing th:nth-child(4),#missing th:nth-child(5){width:19.5%!important}#movies td,#sonarr td,#missing td{padding:7px 4px!important;font-size:10px!important}}';document.head.appendChild(style)});</script>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{const source=document.querySelector('.nav'),brand=document.querySelector('.brand');if(!source||!brand)return;const drawer=document.createElement('aside');drawer.className='mobile-drawer-clone';drawer.innerHTML=source.innerHTML;drawer.querySelector('.mobile-nav-close')?.remove();const close=document.createElement('button');close.className='mobile-drawer-close';close.type='button';close.textContent='×';close.setAttribute('aria-label','Navigation schließen');drawer.prepend(close);const shade=document.createElement('div');shade.className='mobile-drawer-shade';document.body.append(shade,drawer);const closeDrawer=()=>{drawer.classList.remove('open');shade.classList.remove('open');document.body.classList.remove('mobile-nav-open')};const openDrawer=()=>{if(window.matchMedia('(max-width:700px)').matches){drawer.classList.add('open');shade.classList.add('open');document.body.classList.add('mobile-nav-open')}};brand.addEventListener('click',openDrawer);shade.addEventListener('click',closeDrawer);close.addEventListener('click',closeDrawer);drawer.addEventListener('click',event=>{const button=event.target.closest('button[data-view]');if(!button)return;source.querySelector(`button[data-view="${button.dataset.view}"]`)?.click();closeDrawer()})});</script>
|
||||
<style>.mobile-drawer-clone,.mobile-drawer-shade{display:none}@media(max-width:700px){.mobile-drawer-shade{display:none;position:fixed!important;inset:0!important;z-index:1900!important;background:rgba(0,0,0,.62)!important;backdrop-filter:blur(3px)}.mobile-drawer-shade.open{display:block!important}.mobile-drawer-clone{display:flex!important;position:fixed!important;visibility:visible!important;opacity:1!important;top:0!important;left:0!important;bottom:0!important;width:min(84vw,320px)!important;z-index:2000!important;flex-direction:column!important;gap:5px!important;padding:62px 12px 18px!important;margin:0!important;overflow-y:auto!important;overflow-x:hidden!important;background:#1c2027!important;border-right:1px solid #465160!important;box-shadow:16px 0 45px rgba(0,0,0,.5)!important;transform:translate3d(-110%,0,0)!important;transition:transform .24s ease!important}.mobile-drawer-clone.open{transform:translate3d(0,0,0)!important}.mobile-drawer-clone button{display:block!important;flex:0 0 auto!important;width:100%!important;min-height:42px!important;padding:12px 13px!important;text-align:left!important;border:0!important;border-radius:8px!important;background:transparent!important;color:#c7d0dc!important;font-size:13px!important;white-space:nowrap!important}.mobile-drawer-clone button.active{background:#303b49!important;color:#fff!important}.mobile-drawer-clone .mobile-drawer-close{position:absolute!important;top:14px!important;right:12px!important;width:34px!important;min-height:34px!important;height:34px!important;padding:0!important;border:1px solid #4a5563!important;background:#292f38!important;color:#dce8f3!important;text-align:center!important;font-size:23px!important;line-height:1!important}.mobile-drawer-clone img{width:15px!important;height:15px!important;margin-right:6px!important;vertical-align:-3px!important;object-fit:contain!important}}</style>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{const close=document.querySelector('.mobile-nav-close');if(close){close.style.fontSize='23px';close.style.padding='0'}});</script>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{const brand=document.querySelector('.brand'),nav=document.querySelector('.nav');if(!brand||!nav)return;const backdrop=document.createElement('div');backdrop.className='mobile-nav-backdrop';const close=document.createElement('button');close.className='mobile-nav-close';close.type='button';close.setAttribute('aria-label','Navigation schließen');close.textContent='×';nav.prepend(close);document.body.appendChild(backdrop);const drawerStyle=document.createElement('style');drawerStyle.textContent='@media(max-width:700px){.nav.mobile-drawer-open{position:fixed!important;display:flex!important;visibility:visible!important;opacity:1!important;top:0!important;left:0!important;bottom:0!important;width:min(82vw,310px)!important;max-width:none!important;overflow-y:auto!important;overflow-x:hidden!important;transform:translate3d(0,0,0)!important;z-index:1000!important}.nav:not(.mobile-drawer-open){transform:translate3d(-105%,0,0)!important}}';document.head.appendChild(drawerStyle);const closeNav=()=>{document.body.classList.remove('mobile-nav-open');nav.classList.remove('mobile-drawer-open')};const openNav=()=>{if(window.matchMedia('(max-width: 700px)').matches){document.body.classList.add('mobile-nav-open');nav.classList.add('mobile-drawer-open')}};brand.addEventListener('click',openNav);backdrop.addEventListener('click',closeNav);close.addEventListener('click',closeNav);nav.addEventListener('click',event=>{if(event.target.closest('button[data-view]'))closeNav()})});</script>
|
||||
<style>.mobile-nav-close,.mobile-nav-backdrop{display:none}@media(max-width:700px){.brand{cursor:pointer}.mobile-nav-backdrop{position:fixed;inset:0;z-index:130;background:rgba(0,0,0,.58);backdrop-filter:blur(2px)}body.mobile-nav-open .mobile-nav-backdrop{display:block}.nav{position:fixed;top:0;bottom:0;left:0;z-index:140;width:min(82vw,310px);margin:0!important;padding:62px 12px 18px!important;display:flex;flex-direction:column;align-items:stretch;gap:5px;overflow-y:auto;border:0!important;border-right:1px solid #3c4653!important;border-radius:0!important;background:rgba(28,32,39,.98)!important;box-shadow:14px 0 40px rgba(0,0,0,.35);transform:translateX(-105%);transition:transform .24s ease}.nav button{flex:0 0 auto!important;text-align:left;padding:12px 13px!important;border-radius:8px!important;font-size:13px!important}.nav button.active{background:#303b49}.mobile-nav-close{display:block;position:absolute;top:14px;right:12px;width:34px;height:34px;padding:0;border:1px solid #4a5563;border-radius:8px;background:#292f38;color:#dce8f3;font-size:23px;line-height:1}.mobile-nav-open .nav{transform:translateX(0)}body.mobile-nav-open{overflow:hidden}#movies,#sonarr table,#missing table{min-width:0;width:100%;table-layout:fixed}#movies th:nth-child(3),#movies td:nth-child(3),#movies th:nth-child(6),#movies td:nth-child(6),#movies th:nth-child(7),#movies td:nth-child(7),#sonarr th:nth-child(3),#sonarr td:nth-child(3),#sonarr th:nth-child(6),#sonarr td:nth-child(6),#sonarr th:nth-child(7),#sonarr td:nth-child(7),#missing th:nth-child(3),#missing td:nth-child(3),#missing th:nth-child(6),#missing td:nth-child(6),#missing th:nth-child(7),#missing td:nth-child(7){display:none}#movies th:nth-child(1),#sonarr th:nth-child(1),#missing th:nth-child(1){width:46%}#movies th:nth-child(2),#sonarr th:nth-child(2),#missing th:nth-child(2){width:18%}#movies th:nth-child(4),#movies th:nth-child(5),#sonarr th:nth-child(4),#sonarr th:nth-child(5),#missing th:nth-child(4),#missing th:nth-child(5){width:18%}#movies td,#sonarr td,#missing td{padding:8px 6px;font-size:11px}.badges{gap:3px}.badge{padding:2px 5px;font-size:10px}.title{max-width:100%}.tablewrap{overflow-x:hidden}.series-category>.series table{min-width:0!important}}</style>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{const panel=document.querySelector('.system-panel'),diskList=document.querySelector('#diskList');if(!panel||!diskList)return;const extra=document.createElement('div');extra.id='systemDetails';extra.className='system-details';diskList.before(extra);const formatUptime=seconds=>{const days=Math.floor(seconds/86400),hours=Math.floor(seconds%86400/3600),minutes=Math.floor(seconds%3600/60);return `${days?days+' T ':''}${hours} Std. ${minutes} Min.`};const update=async()=>{try{const data=await (await fetch('/api/system')).json();const load=data.load||[];extra.innerHTML=`<div class="system-detail"><span class="system-detail-icon"><img src="https://api.iconify.design/mdi:chart-line.svg?color=%2360a9e6" alt="" aria-hidden="true"></span><div><small>Load Average</small><strong>${load.map(value=>Number(value).toFixed(2)).join(' · ')||'—'}</strong><em>1 / 5 / 15 Minuten</em></div></div><div class="system-detail"><span class="system-detail-icon"><img src="https://api.iconify.design/mdi:clock-outline.svg?color=%23d7adf3" alt="" aria-hidden="true"></span><div><small>Uptime</small><strong>${formatUptime(Number(data.uptime_seconds)||0)}</strong><em>Container-Laufzeit</em></div></div><div class="system-detail"><span class="system-detail-icon"><img src="https://api.iconify.design/mdi:application-cog-outline.svg?color=%23f4d267" alt="" aria-hidden="true"></span><div><small>Prozesse</small><strong>${Number(data.processes||0).toLocaleString('de-DE')}</strong><em>Aktive Prozesse</em></div></div>`}catch(error){extra.innerHTML='<div class="system-detail muted">Systemdetails momentan nicht verfügbar</div>'}};update();setInterval(update,5000)});</script>
|
||||
<style>.system-details{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:2px 0 17px}.system-detail{display:flex;align-items:center;gap:9px;padding:10px 11px;border:1px solid rgba(255,255,255,.07);border-radius:9px;background:rgba(40,43,49,.58)}.system-detail-icon{display:grid;place-items:center;flex:0 0 auto;width:28px;height:28px;border-radius:8px;background:rgba(96,169,230,.14)}.system-detail-icon img{width:17px;height:17px}.system-detail small,.system-detail em{display:block}.system-detail small{color:#9da5b2;font-size:10px}.system-detail strong{display:block;margin-top:2px;color:#f1f5fa;font-size:13px}.system-detail em{margin-top:2px;color:#7f8996;font-size:9px;font-style:normal}@media(max-width:700px){.system-details{grid-template-columns:1fr 1fr}.system-detail:last-child{grid-column:1 / -1}}</style>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{const icons={overview:['mdi:movie-open-outline','mdi:television-classic','mdi:alert-outline','mdi:download-box-outline'],insight:['mdi:movie-check-outline','mdi:playlist-check','mdi:database-check-outline','mdi:monitor-eye']};const apply=(selector,keys)=>{const colors=selector.includes('overview')?['%238bc9f2','%23d7adf3','%23ff9e9e','%236ae29a']:['%238bc9f2','%23d7adf3','%23f4d267','%236ae29a'];document.querySelectorAll(selector).forEach((node,index)=>{const key=keys[index%keys.length];if(!key||node.dataset.iconApplied===key)return;const image=document.createElement('img');image.src=`https://api.iconify.design/${key}.svg?color=${colors[index%colors.length]}`;image.alt='';image.setAttribute('aria-hidden','true');node.textContent='';node.appendChild(image);node.dataset.iconApplied=key})};const update=()=>{apply('.overview-icon',icons.overview);apply('.insight-icon',icons.insight)};update();new MutationObserver(update).observe(document.body,{childList:true,subtree:true})});</script>
|
||||
<style>.overview-icon img{width:23px;height:23px;object-fit:contain}.insight-icon img{width:17px;height:17px;object-fit:contain}</style>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{const badge=document.querySelector('#raidStatusBadge');if(!badge)return;const side=document.createElement('div');side.className='raid-status-side';const countdown=document.createElement('small');countdown.id='raidRefreshCountdown';countdown.textContent='Update in 5s';badge.replaceWith(side);side.append(badge,countdown);const interval=5000;let next=Date.now()+interval;const reset=()=>{next=Date.now()+interval};window.resetRaidRefreshCountdown=reset;const update=()=>{const seconds=Math.max(0,Math.ceil((next-Date.now())/1000));countdown.textContent=`Update in ${seconds}s`;if(seconds===0)reset()};update();setInterval(update,250);const updated=document.querySelector('#datastoreUpdated');if(updated)new MutationObserver(reset).observe(updated,{childList:true,characterData:true,subtree:true})});</script>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{const icons={total:'mdi:harddisk',used:'mdi:database',free:'mdi:database-outline',percent:'mdi:chart-donut'};document.querySelectorAll('.storage-card .storage-icon').forEach((node,index)=>{const key=['total','used','free','percent'][index];if(!key)return;const image=document.createElement('img');image.src=`https://api.iconify.design/${icons[key]}.svg?color=${key==='used'?'%23f4d267':key==='free'?'%236ae29a':key==='percent'?'%23c59cff':'%2360a9e6'}`;image.alt='';image.setAttribute('aria-hidden','true');node.textContent='';node.appendChild(image)})});</script>
|
||||
<script>document.addEventListener('DOMContentLoaded',()=>{if(!document.querySelector('link[rel="shortcut icon"]')){const favicon=document.createElement('link');favicon.rel='shortcut icon';favicon.type='image/svg+xml';favicon.href="{{ url_for('static', filename='media-mark.svg') }}?v=media-max";document.head.appendChild(favicon)}});</script>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#1f252d"><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-capable" content="yes"><link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}?v=7"><link rel="apple-touch-icon" href="{{ url_for('static', filename='media-mark-192.png') }}"><link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='media-mark.svg') }}"><title>Media Max</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#181818;--panel:#222;--line:#3a3a3a;--text:#dedede;--muted:#929292;--yellow:#f4c430;--blue:#60a9e6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:13px Inter,system-ui,sans-serif}header{height:58px;display:flex;align-items:center;gap:15px;padding:0 20px;background:#242424;border-bottom:1px solid #343434}.logo{color:var(--yellow);font-size:22px;font-weight:900;letter-spacing:1px}.head-title{font-weight:700}.head-sub{font-size:12px;color:var(--muted);margin-top:2px}.actions{margin-left:auto}button,input,select{font:inherit;color:var(--text);background:#292929;border:1px solid #444;border-radius:5px}button{padding:7px 10px;cursor:pointer}main{padding:14px 20px 30px}.nav{display:flex;gap:4px;margin-bottom:12px;border-bottom:1px solid var(--line)}.nav button{border:0;border-radius:5px 5px 0 0;background:transparent;color:#aaa}.nav button.active{background:#303030;color:#fff}.view{display:none}.view.active{display:block}.toolbar{display:grid;grid-template-columns:minmax(280px,1fr) 190px 190px 170px;gap:8px;margin-bottom:10px}input,select{padding:7px 10px;height:34px}.stats{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap}.stat{background:#242424;border:1px solid #363636;padding:5px 9px;border-radius:5px;color:#bbb}.scan{color:#f4c430}.banner{padding:9px 11px;border:1px solid #693b3b;background:#372424;border-radius:5px;margin-bottom:10px;color:#ffd1d1}.tablewrap{border:1px solid #353535;border-radius:6px;overflow:hidden;background:var(--panel)}table{width:100%;border-collapse:collapse;table-layout:fixed}th{padding:8px 10px;background:#282828;border-bottom:1px solid #444;text-align:left;font-size:12px}td{padding:7px 10px;border-bottom:1px solid #343434;vertical-align:middle}.col-title{width:28%}.col-quality{width:12%}.col-state{width:10%}.col-audio{width:17%}.col-subs{width:17%}.col-video{width:8%}.col-size{width:8%}.title{color:var(--blue);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;max-width:90%;vertical-align:bottom}.year{color:var(--muted);font-size:11px;margin-left:5px}.badges{display:flex;gap:4px;flex-wrap:wrap}.badge{display:inline-flex;gap:4px;align-items:center;padding:2px 6px;border-radius:999px;border:1px solid #484848;background:#303030;white-space:nowrap;font-size:11px}.quality{background:#0e7138;border-color:#19894b}.state{font-size:11px;font-weight:700;padding:3px 7px;border-radius:4px;display:inline-block}.state.ok{background:#126f39;color:#d9ffe8}.state.warn{background:#77561e;color:#ffebc4}.state.bad{background:#763030;color:#ffd9d9}.state.neutral{background:#3b3b3b;color:#ccc}.muted{color:var(--muted)}.hidden{display:none}.series{border-bottom:1px solid var(--line)}.series:last-child{border:0}.series summary{padding:12px;cursor:pointer;background:#282828;font-weight:700}.series summary:hover{background:#303030}.series table{border-radius:0}.series td:first-child{padding-left:28px}@media(max-width:1100px){.toolbar{grid-template-columns:1fr 1fr}.col-video,.col-size{display:none}}
|
||||
.brand-mark{width:34px;height:34px;display:grid;place-items:center;filter:drop-shadow(0 0 10px rgba(96,169,230,.24))}.brand-mark svg{width:34px;height:34px}.brand-name{color:var(--yellow);font-size:16px;font-weight:900;letter-spacing:1.5px}.brand{display:flex;align-items:center;gap:9px}
|
||||
.dashboard-hero{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:14px;padding:26px;border:1px solid rgba(96,169,230,.2);border-radius:14px;background:radial-gradient(circle at 85% 15%,rgba(96,169,230,.2),transparent 34%),linear-gradient(135deg,rgba(39,48,61,.96),rgba(27,30,36,.96));box-shadow:0 16px 40px rgba(0,0,0,.22)}.dashboard-hero h1{margin:7px 0 4px;font-size:25px}.dashboard-hero p{margin:0;color:#9da5b2}.live-pill{display:flex;align-items:center;gap:8px;padding:9px 14px;border-radius:999px;background:rgba(21,148,71,.17);border:1px solid rgba(106,226,154,.25);color:#aaf2c5;font-weight:700}.live-dot{width:8px;height:8px;border-radius:50%;background:#6ae29a;box-shadow:0 0 12px #6ae29a}.overview-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}.overview-card{display:flex;align-items:center;gap:12px;min-height:104px;padding:17px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:linear-gradient(145deg,rgba(43,47,55,.92),rgba(29,31,36,.92));box-shadow:0 8px 22px rgba(0,0,0,.16)}.overview-icon{display:grid;place-items:center;width:40px;height:40px;border-radius:11px;background:rgba(96,169,230,.17);color:#8bc9f2;font-size:23px;font-weight:700}.overview-icon.series-icon{background:rgba(146,87,194,.2);color:#d7adf3}.overview-icon.warning{background:rgba(188,70,70,.2);color:#ff9e9e}.overview-icon.download-icon{background:rgba(21,148,71,.2);color:#6ae29a}.overview-card div{min-width:0}.overview-card span:not(.overview-icon){display:block;color:#9da5b2;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.overview-card strong{display:block;margin:3px 0;font-size:22px;color:#f1f5fa}.overview-card small{display:block;color:#7f8996;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-columns{display:grid;grid-template-columns:1.45fr 1fr;gap:14px}.system-panel,.service-panel{padding:19px;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:rgba(31,33,38,.84);box-shadow:0 12px 30px rgba(0,0,0,.18)}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:15px;margin-bottom:17px}.panel-heading h2{margin:0 0 4px;font-size:16px}.panel-heading p{margin:0;color:#7f8996;font-size:12px}.system-meters{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px}.meter-top{display:flex;justify-content:space-between;margin-bottom:7px;color:#cbd1db}.meter-top strong{color:#fff}.meter-track{height:9px;background:#363b44;border-radius:99px;overflow:hidden}.meter-track span{display:block;width:0;height:100%;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#159447);transition:width .45s ease}.disk-list{display:grid;gap:12px}.disk-row{display:grid;grid-template-columns:92px 1fr auto;align-items:center;gap:10px}.disk-name{color:#cbd1db;font-weight:700}.disk-path{display:block;color:#7f8996;font-size:11px;font-weight:400}.disk-bar{height:7px;background:#363b44;border-radius:99px;overflow:hidden}.disk-bar span{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#f4c430);transition:width .45s ease}.disk-values{text-align:right;color:#cbd1db;font-size:11px}.service-row{display:flex;align-items:center;gap:11px;padding:12px 0;border-bottom:1px solid rgba(255,255,255,.07)}.service-row:last-child{border-bottom:0}.service-logo{display:grid;place-items:center;width:34px;height:34px;border-radius:9px;color:#fff;font-weight:800}.radarr-dot{background:#1d7e45}.sonarr-dot{background:#4d65b1}.sab-dot{background:#9a6830}.service-row div{flex:1}.service-row strong,.service-row small{display:block}.service-row small{margin-top:2px;color:#7f8996;font-size:11px}.service-status{padding:4px 8px;border-radius:999px;font-size:11px;font-weight:700}.service-status.online{background:#126f39;color:#d9ffe8}.service-status.offline{background:#3b3b3b;color:#aaa}
|
||||
.dashboard-view{flex-direction:column}.dashboard-view .dashboard-columns{align-items:stretch}.dashboard-view .system-panel,.dashboard-view .service-panel{min-height:0}.dashboard-view .disk-list{gap:18px}.dashboard-view .disk-row{min-height:38px}.dashboard-insights{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}.insight-card{display:flex;align-items:center;gap:11px;padding:13px 15px;border:1px solid rgba(255,255,255,.08);border-radius:10px;background:rgba(31,33,38,.84)}.insight-icon{display:grid;place-items:center;width:32px;height:32px;border-radius:8px;background:rgba(96,169,230,.16);color:#8bc9f2;font-size:16px}.insight-card:nth-child(2) .insight-icon{background:rgba(146,87,194,.18);color:#d7adf3}.insight-card:nth-child(3) .insight-icon{background:rgba(244,196,48,.16);color:#f4c430}.insight-card:nth-child(4) .insight-icon{background:rgba(21,148,71,.18);color:#6ae29a}.insight-card strong,.insight-card small{display:block}.insight-card strong{font-size:14px;color:#f1f5fa}.insight-card small{margin-top:3px;color:#7f8996;font-size:10px}@media(max-width:1000px){.dashboard-insights{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:1000px){.overview-grid{grid-template-columns:repeat(2,1fr)}.dashboard-columns{grid-template-columns:1fr}}@media(max-width:600px){.overview-grid,.system-meters{grid-template-columns:1fr}.dashboard-hero{align-items:flex-start;flex-direction:column;padding:20px}.live-pill{align-self:stretch;justify-content:center}.disk-row{grid-template-columns:78px 1fr auto}}
|
||||
.settings-modal .setting-help{display:block;margin:-3px 0 12px;color:var(--muted);font-size:11px}.settings-modal .setting-select{width:100%;height:38px}.pwa-install{display:none}
|
||||
@media(max-width:700px){body{font-size:12px;padding-bottom:env(safe-area-inset-bottom)}header{height:auto;min-height:62px;padding:9px 12px;gap:9px;align-items:center}.brand-mark,.brand-mark svg{width:30px;height:30px}.brand-name{font-size:14px}.head-title{font-size:12px}.head-sub{font-size:10px}.actions{gap:4px}.actions button{height:34px;padding:0 8px}.settings-button{font-size:15px}.actions #rescan{font-size:0}.actions #rescan::after{content:'↻';font-size:16px}main{padding:10px 10px 24px}.nav{margin-bottom:10px;padding:4px;overflow-x:auto;scrollbar-width:none}.nav::-webkit-scrollbar{display:none}.nav button{flex:0 0 auto;white-space:nowrap;padding:8px 10px;font-size:11px}.toolbar{grid-template-columns:1fr;gap:7px;padding:8px}.toolbar input,.toolbar select{width:100%;height:38px}.stats{gap:6px}.stat{padding:6px 8px}.tablewrap{overflow-x:auto;border-radius:8px}table{min-width:860px}.dashboard-hero{margin-bottom:10px;padding:18px;border-radius:11px}.dashboard-hero h1{font-size:21px}.dashboard-hero p{font-size:11px;line-height:1.4}.overview-grid{grid-template-columns:1fr 1fr;gap:7px}.overview-card{min-height:84px;padding:12px 10px;gap:8px}.overview-icon{width:32px;height:32px;font-size:18px}.overview-card strong{font-size:18px}.overview-card span:not(.overview-icon){font-size:9px}.overview-card small{font-size:9px}.dashboard-columns{gap:8px}.system-panel,.service-panel{padding:14px;border-radius:10px}.system-meters{gap:12px}.panel-heading{margin-bottom:13px}.panel-heading h2{font-size:14px}.panel-heading p{font-size:10px}.disk-row{grid-template-columns:82px 1fr auto;gap:7px}.disk-values{font-size:10px}.download-hero{padding:18px;margin-bottom:10px}.download-hero h2{font-size:19px}.download-hero p{font-size:11px}.download-stats{grid-template-columns:1fr 1fr;gap:7px}.download-card{min-height:78px;padding:10px;gap:8px}.card-icon{width:30px;height:30px;font-size:17px}.card-label{font-size:9px}.download-card strong{font-size:17px}.download-card small{font-size:9px}.settings-modal{max-height:calc(100dvh - 24px);overflow:auto;padding:18px}.settings-head{margin-bottom:15px}.settings-modal label{margin-top:13px}}
|
||||
@media(max-width:390px){.brand-name{display:none}.head-sub{display:none}.overview-grid{grid-template-columns:1fr}.dashboard-hero{padding:15px}.download-stats{grid-template-columns:1fr}}
|
||||
</style><style>.scan-control{display:flex;align-items:center;gap:5px}.scan-control select{height:36px;padding:0 8px;font-size:11px}.scan-control button{white-space:nowrap}.scan-running{position:relative;overflow:hidden;background:linear-gradient(90deg,rgba(244,196,48,.14) var(--scan-progress,0%),rgba(244,196,48,.04) var(--scan-progress,0%))!important}.scan-running::after{content:'';position:absolute;left:0;bottom:0;width:var(--scan-progress,0%);height:2px;background:#f4c430;transition:width .35s ease}@media(max-width:700px){.scan-control select{max-width:108px;font-size:10px}.scan-control button{font-size:0}.scan-control button::after{content:'↻';font-size:16px}}</style><style>.graphics-hero{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:14px;padding:22px 26px;border:1px solid rgba(167,139,250,.24);border-radius:14px;background:radial-gradient(circle at 88% 20%,rgba(167,139,250,.16),transparent 32%),linear-gradient(135deg,rgba(45,40,65,.96),rgba(27,30,36,.96));box-shadow:0 14px 34px rgba(0,0,0,.2)}.graphics-hero h2{margin:7px 0 4px;font-size:22px}.graphics-hero p{margin:0;color:#9da5b2}.graphics-live{padding:8px 12px;border:1px solid rgba(167,139,250,.28);border-radius:999px;color:#dfd2ff;background:rgba(125,91,190,.15);font-size:11px;white-space:nowrap}.graphics-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:12px}.graphics-summary-card{display:flex;align-items:center;gap:11px;min-height:82px;padding:14px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:linear-gradient(145deg,rgba(43,47,55,.92),rgba(29,31,36,.92));box-shadow:0 8px 22px rgba(0,0,0,.16)}.graphics-icon{display:grid;place-items:center;width:35px;height:35px;border-radius:10px;background:rgba(167,139,250,.18);color:#d7c4ff;font-weight:800}.graphics-summary-card small{display:block;color:#9da5b2;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.graphics-summary-card strong{display:block;margin-top:3px;color:#f1f5fa;font-size:18px}.graphics-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:12px}.graphics-card,.graphics-panel{padding:18px;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:rgba(31,33,38,.84);box-shadow:0 12px 30px rgba(0,0,0,.18)}.graphics-card-head,.graphics-card-line,.graphics-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}.graphics-card-head h3,.graphics-panel-head h3{margin:3px 0 0;font-size:16px}.graphics-gpu-index{color:#a78bfa;font-size:10px;text-transform:uppercase;letter-spacing:.07em}.graphics-meta-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:16px 0}.graphics-meta-grid div{padding:9px;border:1px solid rgba(255,255,255,.07);border-radius:8px;background:rgba(10,14,20,.2)}.graphics-meta-grid small{display:block;color:#7f8996;font-size:10px}.graphics-meta-grid strong{display:block;margin-top:4px;color:#e8eef5;font-size:13px}.graphics-card-line{font-size:11px;color:#9da5b2}.graphics-card-line strong{color:#e8eef5}.graphics-track{height:9px;margin-top:7px;border-radius:99px;overflow:hidden;background:#363b44}.graphics-track span{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#a78bfa);transition:width .5s ease}.graphics-details{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:14px;color:#7f8996;font-size:10px}.graphics-empty{padding:20px;text-align:center;color:#7f8996;font-size:11px}.graphics-panel-head{margin-bottom:12px}.graphics-panel-head h3{margin:0}.graphics-panel-head p{margin:4px 0 0;color:#7f8996;font-size:11px}.graphics-processes{display:grid;gap:6px}.graphics-process{display:grid;grid-template-columns:1fr auto auto;gap:14px;padding:9px 10px;border:1px solid rgba(255,255,255,.07);border-radius:8px;background:rgba(40,43,49,.65);font-size:11px}.graphics-process span{color:#9da5b2}@media(max-width:900px){.graphics-summary{grid-template-columns:repeat(2,1fr)}.graphics-cards{grid-template-columns:1fr}}@media(max-width:600px){.graphics-hero{align-items:flex-start;flex-direction:column;padding:18px}.graphics-live{align-self:stretch;text-align:center}.graphics-summary{gap:7px}.graphics-summary-card{min-height:72px;padding:10px}.graphics-summary-card strong{font-size:16px}.graphics-card,.graphics-panel{padding:14px}.graphics-meta-grid{grid-template-columns:repeat(2,1fr)}.graphics-process{grid-template-columns:1fr;gap:3px}}</style><style>.graphics-cards{grid-template-columns:repeat(auto-fit,minmax(520px,1fr));align-items:stretch}.graphics-card{position:relative;overflow:hidden;background:radial-gradient(circle at 100% 0,rgba(167,139,250,.09),transparent 34%),rgba(31,33,38,.9);border-color:rgba(167,139,250,.18)}.graphics-card::before{content:"";position:absolute;inset:0 0 auto;height:3px;background:linear-gradient(90deg,#60a9e6,#a78bfa,#f4c430);opacity:.8}.graphics-card-head{padding-bottom:14px;border-bottom:1px solid rgba(255,255,255,.07)}.graphics-card-head h3{font-Line truncated
|
||||
<header><div class="brand"><div class="brand-mark" aria-label="Media Max"><svg viewBox="0 0 48 48" role="img"><path d="M10 8h28a4 4 0 0 1 4 4v24a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4Z" fill="#20252d" stroke="#60a9e6" stroke-width="2"/><path d="M14 17h20M14 24h13M14 31h20" stroke="#f4c430" stroke-width="3" stroke-linecap="round"/><circle cx="34" cy="24" r="5" fill="#159447" stroke="#c9ffe0" stroke-width="1.5"/></svg></div><div class="brand-name">MEDIA</div></div><div><div class="head-title" data-de="Media Max" data-en="Media Max">Media Max</div><div class="head-sub" data-de="Audio- und Untertitelspuren aus den Mediendateien" data-en="Audio and subtitle tracks from media files">Audio- und Untertitelspuren aus den Mediendateien</div></div><div class="actions"><button class="lang-switch" id="languageToggle" title="Sprache / Language">DE</button><button class="settings-button" id="settingsOpen" title="Einstellungen">⚙</button><div class="scan-control"><select id="scanSource" aria-label="Scan-Quelle"><option value="all">Alles scannen</option><option value="radarr">Nur Radarr</option><option value="sonarr">Nur Sonarr</option><option value="missing">Nur Ohne Deutsch</option></select><button id="rescan" data-de="↻ Neu scannen" data-en="↻ Rescan">↻ Neu scannen</button></div></div></header>
|
||||
<main>
|
||||
<nav class="nav"><button class="active" data-view="dashboard"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:view-dashboard-outline.svg?color=%238bc9f2" alt="" aria-hidden="true">Dashboard</button><button data-view="radarr"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:movie-open-outline.svg?color=%23cc7b19" alt="" aria-hidden="true">RADARR · Filme</button>{% if sonarr_enabled %}<button data-view="sonarr"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:television-classic.svg?color=%2335c5f4" alt="" aria-hidden="true">SONARR · Serien</button>{% endif %}<button data-view="missing"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:alert-outline.svg?color=%23ff9e9e" alt="" aria-hidden="true">Ohne Deutsch <span class="nav-count" id="missingNavCount">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}</span></button>{% if sab_enabled %}<button data-view="downloads"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:download-box-outline.svg?color=%23f4c430" alt="" aria-hidden="true">SABNZBD · Downloads <span class="nav-count download-nav-count hidden" id="downloadNavCount">0</span></button>{% endif %}<button data-view="datastore"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:harddisk.svg?color=%2360a9e6" alt="" aria-hidden="true">Datastore</button><button data-view="graphics"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:expansion-card.svg?color=%23a78bfa" alt="" aria-hidden="true">Graphics</button></nav>
|
||||
<section id="dashboard" class="view active" data-source="dashboard"><div class="dashboard-hero"><div><div class="eyebrow">MEDIA MAX</div><h1>Dashboard</h1><p>Deine gesamte Medienbibliothek und Systemauslastung auf einen Blick.</p></div><div class="live-pill"><span class="live-dot"></span> Live</div></div><div class="overview-grid"><div class="overview-card"><span class="overview-icon movie">▣</span><div><span>Filme</span><strong>{{ rows|length }}</strong><small>Radarr Library</small></div></div><div class="overview-card"><span class="overview-icon series-icon">▤</span><div><span>Serien</span><strong>{{ series|length }}</strong><small>Sonarr Shows</small></div></div><div class="overview-card"><span class="overview-icon warning">⚠</span><div><span>Ohne Deutsch</span><strong id="dashboardMissing">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}</strong><small>Audio oder Untertitel prüfen</small></div></div><div class="overview-card"><span class="overview-icon download-icon">↓</span><div><span>Downloads</span><strong id="dashboardDownloads">—</strong><small id="dashboardRemaining">SABnzbd wird geladen …</small></div></div></div><div class="dashboard-columns"><section class="system-panel"><div class="panel-heading"><div><h2>Systemauslastung</h2><p>Aktuelle Werte aus dem Dashboard-Container</p></div><span id="systemUpdated" class="muted">Wird geladen …</span></div><div class="system-meters"><div class="meter"><div class="meter-top"><span>CPU</span><strong id="cpuValue">—</strong></div><div class="meter-track"><span id="cpuBar"></span></div></div><div class="meter"><div class="meter-top"><span>RAM</span><strong id="ramValue">—</strong></div><div class="meter-track"><span id="ramBar"></span></div></div></div><div id="diskList" class="disk-list"><div class="disk-row muted">Laufwerke werden geladen …</div></div></section><section class="service-panel"><div class="panel-heading"><div><h2>Dienste</h2><p>Verbindung und Bibliotheksstatus</p></div></div><div class="service-row"><span class="service-logo radarr-dot">R</span><div><strong>Radarr</strong><small>Filme & Qualität</small></div><span class="service-status online">Aktiv</span></div><div class="service-row"><span class="service-logo sonarr-dot">S</span><div><strong>Sonarr</strong><small>Serien & Episoden</small></div><span class="service-status {% if sonarr_enabled %}online{% else %}offline{% endif %}">{% if sonarr_enabled %}Aktiv{% else %}Nicht konfiguriert{% endif %}</span></div><div class="service-row"><span class="service-logo sab-dot">↓</span><div><strong>SABnzbd</strong><small>Downloads & Queue</small></div><span id="dashboardSabStatus" class="service-status {% if sab_enabled %}online{% else %}offline{% endif %}">{% if sab_enabled %}Wird geprüft …{% else %}Nicht konfiguriert{% endif %}</span></div></section></div></section>
|
||||
<section id="radarr" class="view" data-source="radarr">
|
||||
<div class="toolbar"><input id="search" type="search" placeholder="Film suchen …"><select id="audioFilter"><option value="">Alle Audio-Sprachen</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="subFilter"><option value="">Alle Untertitel</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="stateFilter"><option value="">Alle Status</option><option>DE + EN</option><option>Deutsch</option><option>English only</option></select></div>
|
||||
<div class="stats"><span class="stat" id="visibleCount"></span><span class="stat scan" id="radarrStatus">Scan wird vorbereitet …</span><span class="stat">Radarr: {{ radarr_media_path }}</span></div>
|
||||
<div class="tablewrap"><table id="movies"><thead><tr><th class="col-title">Film</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>
|
||||
{% for r in rows %}<tr data-title="{{ (r.title ~ ' ' ~ (r.year or ''))|lower }}" data-audio="{{ r.audio_languages|map(attribute='name')|join(',') }}" data-subs="{{ r.subtitle_languages|map(attribute='name')|join(',') }}" data-state="{{ r.state.label }}"><td><span class="title">{{ r.title }}</span><span class="year">{{ r.year or '' }}</span>{% if r.error %} ⚠{% endif %}</td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}
|
||||
</tbody></table></div></section>
|
||||
{% if sonarr_enabled %}<section id="sonarr" class="view" data-source="sonarr"><div class="toolbar series-toolbar"><input id="seriesSearch" type="search" placeholder="Serie suchen …"><select id="seriesAudioFilter"><option value="">Alle Audio-Sprachen</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="seriesSubFilter"><option value="">Alle Untertitel</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="seriesStateFilter"><option value="">Alle Status</option><option>DE + EN</option><option>Deutsch</option><option>English only</option></select></div><div class="stats"><span class="stat" id="seriesCount">{{ series|length }} Serien</span><span class="stat scan" id="sonarrStatus">Scan wird vorbereitet …</span><span class="stat">Dashboard: {{ local_series_path }}</span></div><div class="tablewrap" id="seriesList">
|
||||
{% for category in series_categories %}{% if category.series %}<section class="series-category"><div class="series-category-heading"><h2>{{ category.label }}</h2><span>{{ category.series|length }} Serien</span></div>{% for group in category.series %}<details class="series" data-title="{{ group.title|lower }}" open><summary>{{ group.title }} <span class="muted">({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden</span></summary><table><thead><tr><th class="col-title">Episode</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in group.episodes %}<tr data-title="{{ (r.title ~ ' ' ~ (r.year or '') ~ ' ' ~ group.title)|lower }}" data-audio="{{ r.audio_languages|map(attribute='name')|join(',') }}" data-subs="{{ r.subtitle_languages|map(attribute='name')|join(',') }}" data-state="{{ r.state.label }}"><td><span class="title">{{ r.title }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table></details>{% endfor %}</section>{% endif %}{% endfor %}
|
||||
</div></section>{% endif %}
|
||||
<section id="missing" class="view" data-source="missing"><div class="stats"><span class="stat" id="missingCount">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} Einträge ohne Deutsch</span><span class="stat scan" id="missingStatus">Scan wird vorbereitet …</span><span class="stat scan" id="seasonZeroHint">Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet</span></div><div class="tablewrap missing-table">
|
||||
<table id="missingMovies"><thead><tr><th class="col-title">Film</th><th class="col-quality">Qualität</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in missing_german_movies %}<tr><td><span class="title">{{ r.title }}</span><span class="year">{{ r.year or '' }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state bad">Ohne Deutsch</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table>
|
||||
{% for group in missing_german_series %}<details class="series missing-series"><summary>{{ group.title }} <span class="muted">({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden ohne Deutsch</span></summary><table><thead><tr><th class="col-title">Episode</th><th class="col-quality">Qualität</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in group.episodes %}<tr><td><span class="title">{{ r.title }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state bad">Ohne Deutsch</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table></details>{% endfor %}
|
||||
{% if not missing_german_movies and not missing_german_series %}<div class="empty-state">Alle vorhandenen Filme und Episoden haben deutsches Audio oder deutsche Untertitel.</div>{% endif %}</div></section>
|
||||
{% if sab_enabled %}<section id="downloads" class="view" data-source="downloads"><div class="download-hero"><div><div class="eyebrow">SABNZBD · DOWNLOAD CENTER</div><h2>Download-Übersicht</h2><p>Queue, Fortschritt und verbleibender Speicherplatz auf einen Blick.</p></div><div class="queue-pill" id="sabQueueCard"><span class="queue-dot"></span><span>Verbinde mit SABnzbd …</span></div></div><div class="download-stats"><div class="download-card remaining-card"><span class="card-icon">↓</span><div><span class="card-label">Noch zu laden</span><strong id="sabRemaining">—</strong><small id="sabRemainingHint">Wird berechnet …</small></div></div><div class="download-card"><span class="card-icon blue">◉</span><div><span class="card-label">Aktive Downloads</span><strong id="sabActive">—</strong><small id="sabHistory">Verlauf: —</small></div></div><div class="download-card"><span class="card-icon purple">↯</span><div><span class="card-label">Geschwindigkeit</span><strong id="sabSpeedCard">—</strong><small>Aktuelle Queue</small></div></div><div class="download-card"><span class="card-icon orange">✓</span><div><span class="card-label">Status</span><strong id="sabStatusCard">—</strong><small id="downloadCount">—</small></div></div></div><div id="downloadError" class="banner hidden"></div><div class="tablewrap download-table"><table id="downloadsTable"><thead><tr><th class="col-title">Download</th><th class="col-state">Status</th><th class="col-progress">Fortschritt</th><th class="col-size">Größe / Rest</th><th class="col-time">Restzeit</th><th class="col-source">Quelle</th><th class="col-language">Sprache</th></tr></thead><tbody id="downloadsBody"><tr><td colspan="7" class="muted">Downloads werden geladen …</td></tr></tbody></table></div></section>{% endif %}
|
||||
<section id="graphics" class="view" data-source="graphics"><div class="graphics-hero"><div><div class="eyebrow">MEDIA MAX · GRAPHICS</div><h2>NVIDIA Graphics</h2><p>GPU-Auslastung, Speicher, Temperatur, Leistung und laufende Prozesse.</p></div><div id="graphicsStatus" class="graphics-live">Wird geladen …</div></div><div id="graphicsSummary" class="graphics-summary"><div class="graphics-summary-card"><span class="graphics-icon">▣</span><div><small>GPUs</small><strong>—</strong></div></div><div class="graphics-summary-card"><span class="graphics-icon">◒</span><div><small>GPU-Auslastung</small><strong>—</strong></div></div><div class="graphics-summary-card"><span class="graphics-icon">▤</span><div><small>VRAM</small><strong>—</strong></div></div><div class="graphics-summary-card"><span class="graphics-icon">°</span><div><small>Temperatur</small><strong>—</strong></div></div></div><div id="graphicsCards" class="graphics-cards"><div class="graphics-empty">GPU-Daten werden geladen …</div></div><section class="graphics-panel"><div class="graphics-panel-head"><div><h3>GPU-Prozesse</h3><p>Aktuelle Compute-Prozesse laut NVIDIA SMI</p></div></div><div id="graphicsProcesses" class="graphics-processes"><div class="graphics-empty">Keine Prozesse erkannt</div></div></section></section><section id="datastore" class="view" data-source="datastore"><div class="datastore-hero"><div><div class="eyebrow">MEDIA MAX · DATASTORE</div><h2>Datastore</h2><p>Speicherbelegung, Medienkategorien und RAID-Status von <code>/nesflix</code>.</p></div><div id="datastoreUpdated" class="datastore-live">Wird geladen …</div></div><div class="datastore-storage"><div class="storage-card"><span class="storage-icon">▣</span><div><small>Gesamtspeicher</small><strong id="datastoreTotal">—</strong></div></div><div class="storage-card"><span class="storage-icon used">◒</span><div><small>Belegt</small><strong id="datastoreUsed">—</strong></div></div><div class="storage-card"><span class="storage-icon free">◌</span><div><small>Frei</small><strong id="datastoreFree">—</strong></div></div><div class="storage-card"><span class="storage-icon percent">%</span><div><small>Auslastung</small><strong id="datastorePercent">—</strong></div></div></div><section class="datastore-panel datastore-capacity"><div class="datastore-panel-head"><div><h3>Gesamtauslastung</h3><p id="datastorePath">/nesflix</p></div><strong id="datastoreCapacityLabel">—</strong></div><div class="datastore-progress"><span id="datastoreCapacityBar"></span></div></section><div class="datastore-grid"><section class="datastore-panel"><div class="datastore-panel-head"><div><h3>Speicher nach Kategorie</h3><p>Anhand der tatsächlichen Verzeichnisse</p></div></div><div id="datastoreCategories" class="datastore-categories"><div class="datastore-empty">Wird geladen …</div></div></section><section class="datastore-panel raid-panel"><div class="datastore-panel-head"><div><h3>RAID-Status</h3><p id="raidDevice">/dev/md127</p></div><span id="raidStatusBadge" class="raid-status unknown">Wird geladen …</span></div><div id="raidSummary" class="raid-summary"><span>—</span><span>—</span><span>—</span></div><div id="raidRecovery" class="raid-recovery hidden"><div class="recovery-head"><strong>RAID Rebuild</strong><span id="raidRecoveryPercent">—</span></div><div class="datastore-progress recovery"><span id="raidRecoveryBar"></span></div><div class="recovery-meta"><span>Geschwindigkeit <strong id="raidRecoverySpeed">—</strong></span><span>Verbleibend <strong id="raidRecoveryFinish">—</strong></span></div></div><div id="raidDisks" class="raid-disks"><div class="datastore-empty">Wird geladen …</div></div></section></div></section>
|
||||
</main>
|
||||
<div class="settings-backdrop hidden" id="settingsBackdrop"><section class="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settingsTitle"><div class="settings-head"><div><h2 id="settingsTitle" data-de="Einstellungen" data-en="Settings">Einstellungen</h2><p data-de="Deine Anzeige- und Sprachpräferenzen" data-en="Your display and language preferences">Deine Anzeige- und Sprachpräferenzen</p></div><button class="settings-close" id="settingsClose" aria-label="Schließen">×</button></div><label for="preferredAudio" data-de="Bevorzugte Audio-Sprache" data-en="Preferred audio language">Bevorzugte Audio-Sprache</label><select id="preferredAudio"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option><option value="Deutsch" data-de="Deutsch" data-en="German">Deutsch</option><option value="Englisch" data-de="Englisch" data-en="English">Englisch</option><option value="Japanisch" data-de="Japanisch" data-en="Japanese">Japanisch</option><option value="Koreanisch" data-de="Koreanisch" data-en="Korean">Koreanisch</option></select><label for="preferredSubs" data-de="Bevorzugte Untertitel-Sprache" data-en="Preferred subtitle language">Bevorzugte Untertitel-Sprache</label><select id="preferredSubs"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option><option value="Deutsch" data-de="Deutsch" data-en="German">Deutsch</option><option value="Englisch" data-de="Englisch" data-en="English">Englisch</option><option value="Japanisch" data-de="Japanisch" data-en="Japanese">Japanisch</option><option value="Koreanisch" data-de="Koreanisch" data-en="Korean">Koreanisch</option></select><div class="settings-actions"><button id="settingsCancel" data-de="Abbrechen" data-en="Cancel">Abbrechen</button><button class="settings-save" id="settingsSave" data-de="Speichern" data-en="Save">Speichern</button></div></section></div>
|
||||
<script>
|
||||
const dashboardView=document.querySelector('#dashboard');if(dashboardView){dashboardView.classList.add('dashboard-view');const stats={{ dashboard_stats|tojson }};const insights=document.createElement('div');insights.className='dashboard-insights';insights.innerHTML=`<div class="insight-card"><span class="insight-icon">▣</span><div><strong>${stats.movie_files} von ${stats.movie_total} Filmen vorhanden</strong><small>${stats.movie_missing} fehlen · ${stats.movie_downloading} werden geladen</small></div></div><div class="insight-card"><span class="insight-icon">▤</span><div><strong>${stats.episode_files} von ${stats.episode_total} Episoden vorhanden</strong><small>${stats.episode_missing} fehlen in ${stats.series_total} Serien</small></div></div><div class="insight-card"><span class="insight-icon">◌</span><div><strong>Medienbestand geprüft</strong><small>Audio, Untertitel und Qualität werden überwacht</small></div></div><div class="insight-card"><span class="insight-icon">✓</span><div><strong>Live-Überwachung aktiv</strong><small>Systemwerte und Downloads aktualisieren sich automatisch</small></div></div>`;const columns=dashboardView.querySelector('.dashboard-columns');if(columns)dashboardView.insertBefore(insights,columns)}
|
||||
const servicePanel=document.querySelector('.service-panel');let serviceList=null;const serviceSummary=document.createElement('span');serviceSummary.className='service-summary';if(servicePanel){servicePanel.querySelectorAll('.service-row').forEach(row=>row.remove());serviceList=document.createElement('div');serviceList.className='service-list';servicePanel.appendChild(serviceList);const heading=servicePanel.querySelector('.panel-heading');if(heading)heading.appendChild(serviceSummary)}function renderServices(data){if(!serviceList)return;const values=data.services||[];serviceSummary.textContent=`${data.online||0} / ${data.total||values.length} online`;serviceList.innerHTML=values.map(service=>`<div class="service-row service-${service.status}"><span class="service-logo service-icon-${service.key}">${service.key==='dashboard'?'⌂':service.name.slice(0,1)}</span><div><strong>${html(service.name)}</strong><small>${html(service.detail||'Keine Antwort')}</small></div><span class="service-status ${service.status}">${html(service.label)}</span></div>`).join('')}async function fetchServices(){try{renderServices(await (await fetch('/api/services')).json())}catch(error){serviceSummary.textContent='Status nicht verfügbar'}}if(servicePanel){fetchServices();setInterval(fetchServices,30000)}
|
||||
const navigation=document.querySelector('.nav');if(navigation&&!navigation.querySelector('[data-view="datastore"]')){const datastoreButton=document.createElement('button');datastoreButton.dataset.view='datastore';datastoreButton.innerHTML='<img class="nav-icon" src="https://api.iconify.design/mdi:harddisk.svg?color=%2360a9e6" alt="" aria-hidden="true"><span data-de="Datastore" data-en="Datastore">Datastore</span>';navigation.appendChild(datastoreButton)}const rows=[...document.querySelectorAll('#movies tbody tr')], search=document.querySelector('#search'), audio=document.querySelector('#audioFilter'), subs=document.querySelector('#subFilter'), state=document.querySelector('#stateFilter'), count=document.querySelector('#visibleCount');
|
||||
function apply(){const q=search.value.trim().toLowerCase();let n=0;rows.forEach(r=>{const ok=(!q||r.dataset.title.includes(q))&&(!audio.value||r.dataset.audio.includes(audio.value))&&(!subs.value||r.dataset.subs.includes(subs.value))&&(!state.value||r.dataset.state===state.value);r.classList.toggle('hidden',!ok);if(ok)n++});count.textContent=`${n} Filme`};[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));apply();const seriesRows=[...document.querySelectorAll('#seriesList details.series')],seriesSearch=document.querySelector('#seriesSearch'),seriesAudio=document.querySelector('#seriesAudioFilter'),seriesSubs=document.querySelector('#seriesSubFilter'),seriesState=document.querySelector('#seriesStateFilter'),seriesCount=document.querySelector('#seriesCount');function applySeries(){if(!seriesSearch||!seriesAudio||!seriesSubs||!seriesState)return;const q=seriesSearch.value.trim().toLowerCase();let visible=0;seriesRows.forEach(detail=>{const summary=detail.dataset.title||detail.querySelector('summary')?.textContent.toLowerCase()||'';let matches=0;detail.querySelectorAll('tbody tr:not(.season-divider)').forEach(row=>{const ok=(!q||summary.includes(q)||row.dataset.title.includes(q))&&(!seriesAudio.value||row.dataset.audio.includes(seriesAudio.value))&&(!seriesSubs.value||row.dataset.subs.includes(seriesSubs.value))&&(!seriesState.value||row.dataset.state===seriesState.value);row.classList.toggle('hidden',!ok);row.style.display=ok?'':'none';if(ok)matches++});detail.classList.toggle('hidden',matches===0);detail.style.display=matches?'':'none';if(matches)visible++});document.querySelectorAll('#seriesList .series-category').forEach(category=>{const hasVisible=[...category.querySelectorAll('details.series')].some(detail=>!detail.classList.contains('hidden'));category.classList.toggle('hidden',!hasVisible)});if(seriesCount)seriesCount.textContent=`${visible} Serien`};[seriesSearch,seriesAudio,seriesSubs,seriesState].forEach(x=>x?.addEventListener(x===seriesSearch?'input':'change',applySeries));applySeries();
|
||||
document.querySelectorAll('.nav button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.nav button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));b.classList.add('active');document.querySelector('#'+b.dataset.view).classList.add('active');const rescan=document.querySelector('#rescan'),sourceSelect=document.querySelector('#scanSource'),canRescan=['radarr','sonarr','missing'].includes(b.dataset.view);if(canRescan)sourceSelect.value=b.dataset.view;rescan.disabled=false;rescan.title='Bibliothek neu scannen';sessionStorage.setItem('dashboard-active-view',b.dataset.view)});const rememberedView=sessionStorage.getItem('dashboard-active-view');if(rememberedView)document.querySelector(`.nav button[data-view="${rememberedView}"]`)?.click();
|
||||
function statusText(s){if(s.state==='running'){const pct=s.total?Math.round(s.done/s.total*100):0;return `Scan läuft · ${s.done}/${s.total||'?'} Dateien · ${pct}%`}if(s.state==='done')return 'Scan abgeschlossen ✓';if(s.state==='error')return 'Scanfehler: '+s.error;return 'Scan wird vorbereitet …'}
|
||||
function updateScanStatus(selector,s){const el=document.querySelector(selector);if(!el)return;const pct=s.total?Math.min(100,Math.round(s.done/s.total*100)):0;el.textContent=statusText(s);el.style.setProperty('--scan-progress',`${pct}%`);el.classList.toggle('scan-running',s.state==='running')}
|
||||
const seenRunning={radarr:false,sonarr:false,missing:false};async function poll(){try{const data=await (await fetch('/api/scan-status')).json();updateScanStatus('#radarrStatus',data.radarr);const sonarr=document.querySelector('#sonarrStatus');if(sonarr)updateScanStatus('#sonarrStatus',data.sonarr);const missing=document.querySelector('#missingStatus');if(missing)updateScanStatus('#missingStatus',data.missing);['radarr','sonarr','missing'].forEach(source=>{if(data[source].state==='running')seenRunning[source]=true;if(seenRunning[source]&&['done','error'].includes(data[source].state)){seenRunning[source]=false;setTimeout(()=>{sessionStorage.setItem('dashboard-active-view',document.querySelector('.nav button.active')?.dataset.view||'dashboard');location.reload()},700)}})}catch(e){} };setInterval(poll,1200);poll();
|
||||
document.querySelector('#rescan').onclick=async()=>{const b=document.querySelector('#rescan'),source=document.querySelector('#scanSource').value;sessionStorage.setItem('dashboard-active-view',document.querySelector('.nav button.active')?.dataset.view||'dashboard');b.disabled=true;b.textContent='Scan läuft …';await fetch('/api/rescan?source='+source,{method:'POST'});b.disabled=false;b.textContent='↻ Neu scannen';poll()};
|
||||
</script>
|
||||
<script>
|
||||
let lastLibrarySync=0;async function pollLibrarySync(){try{const response=await fetch('/api/library-status',{cache:'no-store'}),data=await response.json();if(!data.ready)return;if(!lastLibrarySync){lastLibrarySync=Number(data.updated_at)||0;return}if(Number(data.updated_at)>lastLibrarySync){lastLibrarySync=Number(data.updated_at);sessionStorage.setItem('dashboard-active-view',document.querySelector('.nav button.active')?.dataset.view||'dashboard');location.reload()}}catch(error){}}pollLibrarySync();setInterval(pollLibrarySync,5000);
|
||||
</script>
|
||||
<script>
|
||||
const scanLabel=document.createElement('label');scanLabel.htmlFor='autoScan';scanLabel.textContent='Automatisches Scanning';const scanHelp=document.createElement('span');scanHelp.className='setting-help';scanHelp.textContent='Bibliotheken werden automatisch geprüft, solange das Dashboard geöffnet ist.';const scanSelect=document.createElement('select');scanSelect.id='autoScan';scanSelect.className='setting-select';[['0','Deaktiviert'],['1800000','Alle 30 Minuten'],['3600000','Jede Stunde'],['7200000','Alle 2 Stunden'],['10800000','Alle 3 Stunden'],['14400000','Alle 4 Stunden'],['18000000','Alle 5 Stunden']].forEach(([value,label])=>{const option=document.createElement('option');option.value=value;option.textContent=label;scanSelect.appendChild(option)});const settingsActions=document.querySelector('.settings-actions');if(settingsActions){settingsActions.before(scanLabel,scanHelp,scanSelect)}
|
||||
function configureAutoScan(){}function saveAutoScan(){localStorage.setItem('auto-scan-interval',scanSelect.value)}scanSelect.value=localStorage.getItem('auto-scan-interval')||'0';document.querySelector('#settingsSave').addEventListener('click',saveAutoScan);
|
||||
const seasonZeroLabel=document.createElement('label');seasonZeroLabel.className='setting-toggle';seasonZeroLabel.htmlFor='showSeasonZero';seasonZeroLabel.innerHTML='<input type="checkbox" id="showSeasonZero"><span>Staffel 0 anzeigen</span>';const seasonZeroHelp=document.createElement('span');seasonZeroHelp.className='setting-help';seasonZeroHelp.textContent='Gilt für Sonarr und die Liste Ohne Deutsch.';if(settingsActions){settingsActions.before(seasonZeroLabel,seasonZeroHelp)}
|
||||
function applySeasonZeroPreference(){const show=localStorage.getItem('show-season-zero')==='true';const rows=[...document.querySelectorAll('#sonarr tbody tr:not(.season-divider),#missing .missing-series tbody tr:not(.season-divider)')];rows.forEach(row=>{const title=row.querySelector('.title')?.textContent.trim()||'';row.classList.toggle('season-zero-hidden',!show&&/^S00E/i.test(title))});document.querySelectorAll('#sonarr details.series,#missing details.missing-series').forEach(detail=>{const episodeRows=[...detail.querySelectorAll('tbody tr:not(.season-divider)')];detail.classList.toggle('season-zero-empty',episodeRows.length>0&&episodeRows.every(row=>row.classList.contains('season-zero-hidden')))});const missingCount=document.querySelector('#missingCount'),missingNavCount=document.querySelector('#missingNavCount');if(missingCount||missingNavCount){const movies=document.querySelectorAll('#missingMovies tbody tr:not(.hidden)').length,episodes=document.querySelectorAll('#missing .missing-series tbody tr:not(.season-divider):not(.season-zero-hidden)').length,total=movies+episodes;if(missingCount)missingCount.textContent=`${total} Einträge ohne Deutsch`;if(missingNavCount)missingNavCount.textContent=total}const hint=document.querySelector('#seasonZeroHint');if(hint)hint.textContent=show?'Vorhanden oder wird gerade geladen · Staffel 0 eingeblendet':'Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet'}const seasonZeroToggle=document.querySelector('#showSeasonZero');if(seasonZeroToggle){seasonZeroToggle.checked=localStorage.getItem('show-season-zero')==='true';seasonZeroToggle.addEventListener('change',()=>{localStorage.setItem('show-season-zero',seasonZeroToggle.checked?'true':'false');applySeasonZeroPreference()})}applySeasonZeroPreference();
|
||||
function syncDashboardMissingCounter(){const visibleMissing=document.querySelectorAll('#missingMovies tbody tr:not(.hidden),#missing .missing-series tbody tr:not(.season-zero-hidden):not(.season-divider)').length;const dashboardMissing=document.querySelector('#dashboardMissing');if(dashboardMissing)dashboardMissing.textContent=visibleMissing}syncDashboardMissingCounter();document.querySelector('#showSeasonZero')?.addEventListener('change',()=>setTimeout(syncDashboardMissingCounter,0));
|
||||
function updateMissingHeading(){const heading=document.querySelector('#missingMovies thead th:first-child');if(heading)heading.textContent=document.documentElement.lang==='en'?'Media':'Medien'}updateMissingHeading();document.querySelector('#languageToggle')?.addEventListener('click',()=>setTimeout(updateMissingHeading,0));
|
||||
function addMissingSections(){const view=document.querySelector('#missing'),movieTable=document.querySelector('#missingMovies'),seriesItems=[...document.querySelectorAll('#missing .missing-series')];if(!view)return;const makeHeading=(type,de,en)=>{const heading=document.createElement('div');heading.className=`missing-section-heading ${type}`;heading.dataset.de=de;heading.dataset.en=en;heading.textContent=document.documentElement.lang==='en'?en:de;return heading};if(movieTable&&movieTable.querySelector('tbody tr'))movieTable.before(makeHeading('movies','Filme ohne Deutsch','Movies without German'));if(seriesItems.length)seriesItems[0].before(makeHeading('series','Serien ohne Deutsch','Series without German'))}addMissingSections();
|
||||
</script>
|
||||
<style>
|
||||
#dashboard.view.active{display:flex}.view.dashboard-view:not(.active){display:none}.meter:first-child .meter-top span::before{content:'◔';display:inline-grid;place-items:center;width:20px;height:20px;margin-right:7px;border-radius:6px;background:rgba(96,169,230,.18);color:#8bc9f2;font-size:13px}.meter:nth-child(2) .meter-top span::before{content:'▤';display:inline-grid;place-items:center;width:20px;height:20px;margin-right:7px;border-radius:6px;background:rgba(146,87,194,.2);color:#d7adf3;font-size:13px}.disk-name::before{content:'▰';display:grid;grid-column:1;grid-row:1 / span 2;place-items:center;width:20px;height:20px;margin:0;border-radius:6px;background:rgba(244,196,48,.16);color:#f4c430;font-size:11px}.meter-top span,.disk-name{display:flex;align-items:center}.disk-name{display:grid;grid-template-columns:20px minmax(0,1fr);grid-template-rows:auto auto;column-gap:7px;align-items:center;min-width:92px;line-height:1.1}.disk-name:not(:has(.disk-path)){grid-template-rows:auto}.disk-name .disk-path{grid-column:2;grid-row:2;white-space:nowrap}.disk-name .disk-path:empty{display:none}
|
||||
</style><style>.nav-count.download-nav-count{background:#f4c430!important;border-color:#ffd95a!important;color:#17191d!important;box-shadow:0 0 10px rgba(244,196,48,.28)}.service-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:20px}.service-summary{margin-left:auto;color:#9da5b2;font-size:12px;font-weight:700}.service-row{min-width:0}.service-row strong,.service-row small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.service-status.timeout,.service-status.error{background:#806b25;color:#fff3bd}.service-status.offline{background:#7a3030;color:#ffd9d9}.service-timeout .service-logo,.service-error .service-logo{background:#806b25!important}.service-offline .service-logo{background:#7a3030!important}@media(max-width:700px){.service-list{grid-template-columns:1fr}.service-summary{font-size:10px}}</style></body></html>
|
||||
<style>
|
||||
details.series{margin-bottom:8px;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:#222}
|
||||
details.series:last-child{margin-bottom:0}
|
||||
.series-category{margin-bottom:16px}.series-category:last-child{margin-bottom:0}.series-category-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 14px;margin-bottom:8px;border:1px solid rgba(96,169,230,.2);border-radius:9px;background:linear-gradient(100deg,rgba(44,55,70,.9),rgba(34,34,34,.9))}.series-category-heading h2{margin:0;color:#e9f2fb;font-size:15px}.series-category-heading span{color:#9da5b2;font-size:11px}
|
||||
.nav-count{display:inline-block;min-width:18px;padding:1px 5px;margin-left:4px;border-radius:999px;background:#713030;color:#ffd9d9;font-size:10px}.empty-state{padding:28px;text-align:center;color:var(--muted)}.missing-table>table{border-bottom:1px solid var(--line)}.missing-series{border-radius:0!important;border-left:0!important;border-right:0!important;border-bottom:1px solid var(--line)!important}.missing-series:last-child{border-bottom:0!important}
|
||||
</style>
|
||||
<style>
|
||||
.view#radarr.active,.view#sonarr.active{row-gap:12px}.view#radarr>.toolbar,.view#sonarr>.series-toolbar{padding:0 10px;background:transparent;border-color:transparent;box-shadow:none}.view#radarr>.toolbar input,.view#radarr>.toolbar select,.view#sonarr>.series-toolbar input,.view#sonarr>.series-toolbar select{background:rgba(42,46,54,.82);border-color:#454d59}.view#radarr>.toolbar input:focus,.view#radarr>.toolbar select:focus,.view#sonarr>.series-toolbar input:focus,.view#sonarr>.series-toolbar select:focus{background:#303640}
|
||||
.season-zero-hidden,.season-zero-empty{display:none!important}.setting-toggle{display:flex!important;align-items:center;gap:10px;margin:18px 0 6px!important;cursor:pointer}.setting-toggle input{appearance:none;width:42px;height:23px;margin:0;border:1px solid #505967;border-radius:999px;background:#30343b;position:relative;cursor:pointer;transition:.2s}.setting-toggle input::after{content:"";position:absolute;width:17px;height:17px;left:2px;top:2px;border-radius:50%;background:#9da5b2;transition:.2s}.setting-toggle input:checked{background:#176f40;border-color:#23965b}.setting-toggle input:checked::after{transform:translateX(19px);background:#e4ffef}.setting-toggle span{font-weight:700;color:#cbd1db}
|
||||
.missing-table table{table-layout:fixed}.missing-table tbody tr{transform:none!important;animation:none!important}.missing-table .missing-series tbody tr:hover{transform:none!important}
|
||||
.missing-table{padding:10px}.missing-table>table{width:100%;margin:0 0 8px;border:1px solid rgba(255,255,255,.09);border-radius:10px;overflow:hidden}.missing-table .missing-series{margin:8px 0 0!important}.missing-table thead th{height:38px;padding:0 12px;vertical-align:middle;background:#2d3540;border-right:1px solid rgba(255,255,255,.06)}.missing-table thead th:last-child{border-right:0}.missing-table .missing-series summary{min-height:42px;display:flex;align-items:center}
|
||||
.missing-section-heading{display:flex;align-items:center;gap:10px;margin:14px 0 8px;padding:10px 13px;border-radius:9px;font-size:12px;font-weight:800;letter-spacing:.04em;text-transform:uppercase}.missing-section-heading:first-child{margin-top:0}.missing-section-heading::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 10px currentColor}.missing-section-heading.movies{color:#8fc9f2;background:linear-gradient(90deg,rgba(96,169,230,.16),rgba(96,169,230,.04));border:1px solid rgba(96,169,230,.2)}.missing-section-heading.series{color:#e2a9aa;background:linear-gradient(90deg,rgba(190,83,83,.16),rgba(190,83,83,.04));border:1px solid rgba(190,83,83,.2)}
|
||||
</style>
|
||||
<script>document.querySelectorAll('details.series').forEach(series => { series.open = false; });</script>
|
||||
<style>
|
||||
header{z-index:100!important}
|
||||
#seriesList{overflow:visible!important}
|
||||
#seriesList details.series{overflow:visible!important}
|
||||
#seriesList details.series>summary{position:sticky!important;top:64px!important;z-index:30!important}
|
||||
#seriesList .season-divider td{top:110px!important;z-index:29!important}
|
||||
</style>
|
||||
<style>
|
||||
.subtitle-de{background:#126f39!important;border-color:#19894b!important;color:#d9ffe8!important}
|
||||
.audio-preferred{background:#2673a8!important;border-color:#64b5ed!important;color:#eef8ff!important;box-shadow:0 0 12px rgba(96,169,230,.3)}
|
||||
.col-progress{width:16%}.col-time{width:10%}.col-source{width:10%}.col-language{width:12%}
|
||||
.progress-wrap{display:flex;align-items:center;gap:8px}.progress-bar{height:7px;min-width:70px;flex:1;background:#3a3a3a;border-radius:99px;overflow:hidden}.progress-bar span{display:block;height:100%;background:#159447;border-radius:99px}.download-title{color:var(--blue);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
|
||||
.download-hero{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:14px;padding:24px 26px;border:1px solid rgba(96,169,230,.2);border-radius:14px;background:radial-gradient(circle at 88% 20%,rgba(21,148,71,.18),transparent 32%),linear-gradient(135deg,rgba(39,48,61,.96),rgba(27,30,36,.96));box-shadow:0 16px 40px rgba(0,0,0,.22)}.eyebrow{color:#79b9e9;font-size:10px;font-weight:800;letter-spacing:.16em}.download-hero h2{margin:7px 0 4px;font-size:22px}.download-hero p{margin:0;color:#9da5b2}.queue-pill{display:flex;align-items:center;gap:9px;padding:10px 14px;border:1px solid rgba(255,255,255,.12);border-radius:999px;background:rgba(13,16,20,.35);white-space:nowrap;color:#d9dee7}.queue-dot{width:9px;height:9px;border-radius:50%;background:#f4c430;box-shadow:0 0 12px currentColor}.download-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}.download-card{display:flex;align-items:center;gap:12px;min-height:92px;padding:16px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:linear-gradient(145deg,rgba(43,47,55,.92),rgba(29,31,36,.92));box-shadow:0 8px 22px rgba(0,0,0,.16)}.card-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:rgba(21,148,71,.2);color:#6ae29a;font-size:21px;font-weight:700}.card-icon.blue{background:rgba(96,169,230,.18);color:#8bc9f2}.card-icon.purple{background:rgba(146,87,194,.2);color:#d7adf3}.card-icon.orange{background:rgba(244,166,48,.18);color:#ffd383}.download-card div{min-width:0}.card-label{display:block;color:#9da5b2;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.download-card strong{display:block;margin:3px 0;font-size:20px;color:#f1f5fa}.download-card small{display:block;color:#7f8996;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.download-table{box-shadow:0 16px 34px rgba(0,0,0,.24)}
|
||||
@media(max-width:900px){.download-stats{grid-template-columns:repeat(2,1fr)}.download-hero{align-items:flex-start;flex-direction:column}.queue-pill{align-self:stretch;justify-content:center}}@media(max-width:520px){.download-stats{grid-template-columns:1fr}.download-hero{padding:18px}.download-hero h2{font-size:19px}}
|
||||
</style>
|
||||
<style>
|
||||
.missing-table{padding:10px}.missing-table>table{width:100%;margin:0 0 8px;border:1px solid rgba(255,255,255,.09);border-radius:10px;overflow:hidden}.missing-table .missing-series{margin:8px 0 0!important}.missing-table thead th{height:38px;padding:0 12px;vertical-align:middle;background:#2d3540;border-right:1px solid rgba(255,255,255,.06)}.missing-table thead th:last-child{border-right:0}.missing-table .missing-series summary{min-height:42px;display:flex;align-items:center}
|
||||
</style>
|
||||
<style>
|
||||
.datastore-hero{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:14px;padding:22px 26px;border:1px solid rgba(96,169,230,.2);border-radius:14px;background:radial-gradient(circle at 88% 20%,rgba(96,169,230,.14),transparent 32%),linear-gradient(135deg,rgba(39,48,61,.96),rgba(27,30,36,.96));box-shadow:0 14px 34px rgba(0,0,0,.2)}.datastore-hero h2{margin:7px 0 4px;font-size:22px}.datastore-hero p{margin:0;color:#9da5b2}.datastore-hero code{color:#b9d9f1}.datastore-live{padding:8px 12px;border:1px solid rgba(106,226,154,.22);border-radius:999px;color:#aaf2c5;background:rgba(21,148,71,.12);font-size:11px}.datastore-storage{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:12px}.storage-card{display:flex;align-items:center;gap:11px;min-height:82px;padding:14px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:linear-gradient(145deg,rgba(43,47,55,.92),rgba(29,31,36,.92));box-shadow:0 8px 22px rgba(0,0,0,.16)}.storage-icon{display:grid;place-items:center;width:35px;height:35px;border-radius:10px;background:rgba(96,169,230,.16);color:#8bc9f2;font-weight:800}.storage-icon.used{background:rgba(244,196,48,.16);color:#f4d267}.storage-icon.free{background:rgba(21,148,71,.16);color:#6ae29a}.storage-icon.percent{background:rgba(146,87,194,.18);color:#d7adf3}.storage-card small{display:block;color:#9da5b2;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.storage-card strong{display:block;margin-top:3px;color:#f1f5fa;font-size:18px}.datastore-panel{padding:18px;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:rgba(31,33,38,.84);box-shadow:0 12px 30px rgba(0,0,0,.18)}.datastore-capacity{margin-bottom:12px}.datastore-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:14px}.datastore-panel-head h3{margin:0 0 4px;font-size:15px}.datastore-panel-head p{margin:0;color:#7f8996;font-size:11px}.datastore-panel-head>strong{color:#dce9f4;font-size:12px}.datastore-progress{height:10px;border-radius:99px;overflow:hidden;background:#363b44}.datastore-progress span{display:block;height:100%;width:0;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#f4c430);transition:width .5s ease}.datastore-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.datastore-categories{display:grid;gap:13px}.category-row-head{display:flex;justify-content:space-between;gap:10px;color:#cbd1db;font-size:12px}.category-row-head strong{color:#f1f5fa}.category-track{height:7px;margin:6px 0 4px;border-radius:99px;overflow:hidden;background:#363b44}.category-track span{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#159447);transition:width .5s ease}.category-row small{color:#7f8996;font-size:10px}.raid-status{padding:5px 9px;border-radius:999px;font-size:11px;font-weight:800}.raid-status.ok{background:#126f39;color:#d9ffe8}.raid-status.rebuilding{background:#245a91;color:#d9efff}.raid-status.degraded{background:#806b25;color:#fff3bd}.raid-status.failed{background:#7a3030;color:#ffd9d9}.raid-status.unknown{background:#3b3b3b;color:#aaa}.raid-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:14px}.raid-summary span{padding:9px;border:1px solid rgba(255,255,255,.07);border-radius:8px;background:rgba(10,14,20,.2)}.raid-summary small,.raid-summary strong{display:block}.raid-summary small{color:#7f8996;font-size:10px}.raid-summary strong{margin-top:3px;color:#e8eef5;font-size:14px}.raid-disks{display:grid;gap:7px}.raid-disk{display:flex;align-items:center;gap:9px;padding:9px 10px;border:1px solid rgba(255,255,255,.07);border-radius:8px;background:rgba(40,43,49,.65)}.raid-disk>div{min-width:0;flex:1}.raid-disk strong,.raid-disk small{display:block}.raid-disk strong{color:#dce9f4;font-size:12px}.raid-disk small{margin-top:2px;color:#7f8996;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.raid-disk b{font-size:10px;text-transform:uppercase;color:#b8c2cd}.disk-status{width:9px;height:9px;flex:0 0 auto;border-radius:50%;background:#929292}.disk-status.active{background:#6ae29a;box-shadow:0 0 9px #6ae29a}.disk-status.failed{background:#ff8585;box-shadow:0 0 9px #ff8585}.disk-status.spare{background:#b9b9b9}.disk-status.rebuilding{background:#60a9e6;box-shadow:0 0 9px #60a9e6}.disk-status.missing{background:#f4c430}.raid-recovery{margin-bottom:14px;padding:12px;border:1px solid rgba(96,169,230,.2);border-radius:9px;background:rgba(48,69,92,.22)}.recovery-head,.recovery-meta{display:flex;justify-content:space-between;gap:12px}.recovery-head{margin-bottom:8px;color:#ddecfa;font-size:12px}.recovery-meta{margin-top:8px;color:#9da5b2;font-size:10px}.recovery-meta strong{color:#e8eef5}.datastore-empty{padding:16px;text-align:center;color:#7f8996;font-size:11px}@media(max-width:900px){.datastore-storage{grid-template-columns:repeat(2,1fr)}.datastore-grid{grid-template-columns:1fr}}@media(max-width:600px){.datastore-hero{alignLine truncated
|
||||
</style>
|
||||
<script>
|
||||
document.querySelectorAll('#movies tbody tr td:nth-child(5) .badge,#sonarr tbody tr td:nth-child(5) .badge').forEach(badge=>{if(badge.textContent.includes('Deutsch'))badge.classList.add('subtitle-de')});
|
||||
document.querySelectorAll('#movies tbody tr,#sonarr tbody tr').forEach(row=>{if(row.querySelector('.state.downloading'))row.children[1].querySelector('.badge')?.classList.add('downloading-badge')});
|
||||
const downloadTable=document.querySelector('.download-table');if(downloadTable){downloadTable.innerHTML='<section class="download-section"><div class="download-section-head"><h3>Aktive Downloads</h3><span id="activeDownloadSectionCount">0</span></div><table id="downloadsTable"><thead><tr><th class="col-title">Download</th><th class="col-state">Status</th><th class="col-progress">Fortschritt</th><th class="col-size">Größe / Rest</th><th class="col-time">Restzeit</th><th class="col-source">Quelle</th><th class="col-language">Sprache</th></tr></thead><tbody id="activeDownloadsBody"></tbody></table></section><section class="download-section download-secondary"><div class="download-section-head"><h3>Weitere Queue-Einträge</h3><span id="otherDownloadSectionCount">0</span></div><table id="otherDownloadsTable"><thead><tr><th class="col-title">Download</th><th class="col-state">Status</th><th class="col-progress">Fortschritt</th><th class="col-size">Größe / Rest</th><th class="col-time">Restzeit</th><th class="col-source">Quelle</th><th class="col-language">Sprache</th></tr></thead><tbody id="otherDownloadsBody"></tbody></table></section>'}const downloadBody=document.querySelector('#activeDownloadsBody'),otherDownloadBody=document.querySelector('#otherDownloadsBody');
|
||||
function html(value){return String(value??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function renderDownloads(data){if(!downloadBody)return;const items=data.items||[], active=Number(data.active_count)||0, history=Number(data.history_count)||0, paused=!!data.paused, status=data.error?'Fehler':(paused?'Pausiert':(data.queue_status||'Idle'));document.querySelector('#downloadCount').textContent=`${items.length} Einträge`;const dashboardDownloads=document.querySelector('#dashboardDownloads'),dashboardRemaining=document.querySelector('#dashboardRemaining'),dashboardSabStatus=document.querySelector('#dashboardSabStatus');if(dashboardDownloads)dashboardDownloads.textContent=active;if(dashboardRemaining)dashboardRemaining.textContent=data.remaining?`Noch ${data.remaining}`:'Queue ist leer';if(dashboardSabStatus)dashboardSabStatus.textContent=data.error?'Fehler':(paused?'Pausiert':'Aktiv');document.querySelector('#sabRemaining').textContent=data.remaining||'—';document.querySelector('#sabRemainingHint').textContent=active?`${active} Einträge in der Queue`:'Queue ist leer';document.querySelector('#sabActive').textContent=active;document.querySelector('#sabHistory').textContent=`Verlauf: ${history} Einträge`;document.querySelector('#sabSpeedCard').textContent=data.speed||'—';document.querySelector('#sabStatusCard').textContent=status;document.querySelector('#sabQueueCard').innerHTML=`<span class="queue-dot ${paused?'paused':''}"></span><span>${html(paused?'Pausiert':(data.error?'Nicht erreichbar':'SABnzbd '+status))}</span>`;const error=document.querySelector('#downloadError');error.classList.toggle('hidden',!data.error);error.innerHTML=data.error?`<strong>SABnzbd:</strong> ${html(data.error)}`:'';downloadBody.innerHTML=items.length?items.map(item=>{const pct=Math.max(0,Math.min(100,Number(item.percentage)||0));const languages=(item.languages||[]).map(l=>`<span class="badge">${html(l.flag)} ${html(l.name)}</span>`).join('')||'<span class="muted">Nicht erkannt</span>';return `<tr><td><span class="download-title" title="${html(item.title)}">${html(item.title)}</span><span class="muted">${html(item.category)}</span></td><td><span class="state ${item.completed?'ok':'neutral'}">${html(item.status)}</span></td><td><div class="progress-wrap"><div class="progress-bar"><span style="width:${pct}%"></span></div><span>${pct.toFixed(1)}%</span></div></td><td>${html(item.size)}<br><span class="muted">Rest: ${html(item.remaining)}</span></td><td>${html(item.completed?'—':item.timeleft)}</td><td>${html(item.source)}</td><td><div class="badges">${languages}</div></td></tr>`}).join(''):'<tr><td colspan="7" class="muted">Keine aktuellen Downloads</td></tr>'}
|
||||
async function fetchDownloads(){if(!downloadBody)return;try{const response=await fetch('/api/downloads');const data=await response.json();renderDownloads(data);if(typeof markDownloading==='function')markDownloading(data.items)}catch(error){renderDownloads({error:'Dashboard konnte SABnzbd nicht erreichen.',items:[]})}};
|
||||
if(downloadBody){fetchDownloads();setInterval(fetchDownloads,4000)}
|
||||
const diskList=document.querySelector('#diskList');
|
||||
function renderSystem(data){const cpu=Number(data.cpu_percent)||0,ram=data.memory||{};const cpuValue=document.querySelector('#cpuValue'),ramValue=document.querySelector('#ramValue'),cpuBar=document.querySelector('#cpuBar'),ramBar=document.querySelector('#ramBar'),updated=document.querySelector('#systemUpdated');if(cpuValue)cpuValue.textContent=`${cpu.toFixed(1)}%`;if(ramValue)ramValue.textContent=`${ram.used||'—'} / ${ram.total||'—'} (${Number(ram.percent||0).toFixed(1)}%)`;if(cpuBar)cpuBar.style.width=`${Math.min(100,cpu)}%`;if(ramBar)ramBar.style.width=`${Math.min(100,Number(ram.percent)||0)}%`;if(updated)updated.textContent='Gerade aktualisiert';if(!diskList)return;diskList.innerHTML=(data.disks||[]).map(d=>`<div class="disk-row"><div class="disk-name">${html(d.label)}<span class="disk-path">${html(d.path)}</span></div><div class="disk-bar"><span style="width:${Math.min(100,Number(d.percent)||0)}%"></span></div><div class="disk-values">${html(d.used)} / ${html(d.total)}<br><span class="muted">${Number(d.percent||0).toFixed(1)}% belegt</span></div></div>`).join('')||'<div class="disk-row muted">Keine Laufwerke gefunden</div>'}
|
||||
async function fetchSystem(){try{const response=await fetch('/api/system');renderSystem(await response.json())}catch(error){const updated=document.querySelector('#systemUpdated');if(updated)updated.textContent='Nicht verfügbar'}}
|
||||
if(document.querySelector('#dashboard')){fetchSystem();setInterval(fetchSystem,5000)}
|
||||
const graphicsView=document.querySelector('#graphics');function graphicsValue(value,unit){return value===null||value===undefined||value===''?'—':String(value)+(unit||'')}function renderGraphics(data){if(!graphicsView)return;const gpus=data.gpus||[],status=document.querySelector('#graphicsStatus');if(status)status.textContent=data.available?gpus.length+' GPU'+(gpus.length===1?'':'s')+' · Aktualisiert':'Nicht verfügbar';const avg=function(key){const values=gpus.map(function(g){return Number(g[key])}).filter(Number.isFinite);return values.length?Math.round(values.reduce(function(a,b){return a+b},0)/values.length):null};const memUsed=gpus.reduce(function(sum,g){return sum+(Number(g['memory.used'])||0)},0),memTotal=gpus.reduce(function(sum,g){return sum+(Number(g['memory.total'])||0)},0),summary=document.querySelector('#graphicsSummary');if(summary)summary.innerHTML='<div class="graphics-summary-card"><span class="graphics-icon">▣</span><div><small>GPUs</small><strong>'+(gpus.length||'—')+'</strong></div></div><div class="graphics-summary-card"><span class="graphics-icon">◒</span><div><small>GPU-Auslastung</small><strong>'+graphicsValue(avg('utilization.gpu'),'%')+'</strong></div></div><div class="graphics-summary-card"><span class="graphics-icon">▤</span><div><small>VRAM</small><strong>'+(memTotal?(memUsed/1024).toFixed(1)+' / '+(memTotal/1024).toFixed(1)+' GB':'—')+'</strong></div></div><div class="graphics-summary-card"><span class="graphics-icon">°</span><div><small>Temperatur</small><strong>'+graphicsValue(avg('temperature.gpu'),' °C')+'</strong></div></div>';const cards=document.querySelector('#graphicsCards');if(cards)cards.innerHTML=gpus.length?gpus.map(function(g){const used=Number(g['memory.used'])||0,total=Number(g['memory.total'])||0,pct=total?Math.round(used/total*100):0;return '<article class="graphics-card"><div class="graphics-card-head"><div><span class="graphics-gpu-index">GPU '+html(g.index)+'</span><h3>'+html(g.name||'NVIDIA GPU')+'</h3></div><span class="graphics-live">'+graphicsValue(g['temperature.gpu'],' °C')+'</span></div><div class="graphics-meta-grid"><div><small>GPU-Last</small><strong>'+graphicsValue(g['utilization.gpu'],'%')+'</strong></div><div><small>Memory-Last</small><strong>'+graphicsValue(g['utilization.memory'],'%')+'</strong></div><div><small>Leistung</small><strong>'+graphicsValue(g['power.draw'],' W')+(g['power.limit']?' / '+g['power.limit']+' W':'')+'</strong></div><div><small>P-State</small><strong>'+html(g.pstate||'—')+'</strong></div></div><div class="graphics-memory"><div class="graphics-card-line"><span>VRAM</span><strong>'+(total?(used/1024).toFixed(1)+' / '+(total/1024).toFixed(1)+' GB · '+pct+'%':'—')+'</strong></div><div class="graphics-track"><span style="width:'+pct+'%"></span></div></div><div class="graphics-details"><span>Treiber '+html(g.driver_version||'—')+'</span><span>Bus '+html(g['pci.bus_id']||'—')+'</span><span>UUID '+html(g.uuid||'—')+'</span><span>Serial '+html(g.serial||'—')+'</span><span>Clock '+graphicsValue(g['clocks.current.graphics'],' MHz')+'</span></div></article>'}).join(''):'<div class="graphics-empty">'+html(data.error||'Keine NVIDIA-GPU erkannt')+'</div>';const processes=document.querySelector('#graphicsProcesses');if(processes)processes.innerHTML=(data.processes||[]).length?data.processes.map(function(p){return '<div class="graphics-process"><strong>'+html(p.name)+'</strong><span>PID '+html(p.pid)+'</span><span>'+html(p.memory)+'</span></div>'}).join(''):'<div class="graphics-empty">Keine GPU-Prozesse erkannt</div>'}async function fetchGraphics(){if(!graphicsView)return;try{renderGraphics(await (await fetch('/api/graphics',{cache:'no-store'})).json())}catch(error){renderGraphics({available:false,error:'GPU-Status momentan nicht verfügbar'})}}if(graphicsView){fetchGraphics();setInterval(fetchGraphics,5000)}const datastoreView=document.querySelector('#datastore');function renderDatastore(data){const storage=data.storage||{},raid=data.raid||{};const set=(id,value)=>{const node=document.querySelector(id);if(node)node.textContent=value};set('#datastoreTotal',storage.total||'—');set('#datastoreUsed',storage.used||'—');set('#datastoreFree',storage.free||'—');set('#datastorePercent',storage.percent!=null?`${storage.percent}%`:'—');set('#datastorePath',storage.path||'/nesflix');set('#datastoreCapacityLabel',storage.percent!=null?`${storage.used||'—'} / ${storage.total||'—'} · ${storage.percent}%`:storage.error||'Nicht verfügbar');set('#datastoreUpdated',storage.scanned_at?'Gerade aktualisiert':'Nicht verfügbar');const capacityBar=document.querySelector('#datastoreCapacityBar');if(capacityBar)capacityBar.style.width=`${Math.min(100,Number(storage.percent)||0)}%`;const categories=document.querySelector('#datastoreCategories');if(categories){const values=(storage.categories||[]).filter(category=>category.bytes>0||['movies','series','anime','kdrama','downloads'].includes(category.key));categories.innerHLine truncated
|
||||
</script>
|
||||
<style>
|
||||
.season-divider td{padding:6px 10px;background:#1b1b1b;border-top:1px solid #444;border-bottom:1px solid #303030;color:#aaa;font-size:11px;font-weight:700;letter-spacing:.3px}
|
||||
#seriesList{overflow:visible}.series{overflow:visible!important}.series summary{position:sticky!important;top:64px!important;z-index:30!important}.season-divider td{top:110px!important;z-index:29!important}
|
||||
.series[open] table{animation:seriesExpand .38s cubic-bezier(.2,.75,.25,1) both;transform-origin:top center}.series summary::after{content:'⌄';margin-left:auto;color:#929292;font-size:16px;line-height:1;transition:transform .3s ease,color .3s ease}.series[open] summary::after{transform:rotate(180deg);color:#60a9e6}@keyframes seriesExpand{from{opacity:0;transform:translateY(-8px) scaleY(.98)}to{opacity:1;transform:translateY(0) scaleY(1)}}
|
||||
.series{position:relative;margin-bottom:14px;border:1px solid #353b46;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.14);transition:border-color .25s ease,box-shadow .25s ease,transform .25s ease}.series:hover{border-color:#4a5565;box-shadow:0 12px 30px rgba(0,0,0,.24);transform:translateY(-1px)}.series-current{border-color:#557ba5;box-shadow:0 0 0 1px rgba(96,169,230,.18),0 14px 34px rgba(0,0,0,.25)}.series summary{position:sticky;top:0;z-index:5;display:flex;align-items:center;min-height:46px;padding:12px 14px;background:rgba(40,40,40,.96);backdrop-filter:blur(12px);border-bottom:1px solid transparent;transition:background .25s ease,border-color .25s ease,color .25s ease}.series-current summary{background:rgba(47,61,78,.98)}.series summary::marker{color:#60a9e6}.series[open] summary{border-bottom-color:#3d444f;box-shadow:0 4px 14px rgba(0,0,0,.16)}.series summary:hover{background:rgba(54,60,70,.97);color:#fff}.series summary .muted{margin-left:7px}.series table tbody tr:not(.season-divider){animation:rowIn .35s ease both;transition:background .2s ease,transform .2s ease}.series table tbody tr:not(.season-divider):hover{background:#292e36;transform:translateX(2px)}.season-divider td{position:sticky;top:46px;z-index:4;background:rgba(27,27,27,.96);backdrop-filter:blur(8px)}
|
||||
.state.downloading,.downloading-badge{animation:downloadPulse 2s ease-in-out infinite}.progress-bar span{position:relative;overflow:hidden}.progress-bar span::after{content:"";position:absolute;inset:0;background:linear-gradient(110deg,transparent 20%,rgba(255,255,255,.25) 48%,transparent 72%);animation:progressShimmer 1.8s linear infinite}.nav button{transition:color .2s ease,background .2s ease,transform .2s ease}.nav button:hover{transform:translateY(-1px)}.badge{transition:transform .18s ease,filter .18s ease}.badge:hover{transform:translateY(-2px);filter:brightness(1.15)}
|
||||
@keyframes rowIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}@keyframes downloadPulse{0%,100%{box-shadow:0 0 0 rgba(113,60,158,0)}50%{box-shadow:0 0 14px rgba(146,87,194,.35)}}@keyframes progressShimmer{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
|
||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
.view#sonarr{padding-bottom:4px}.view#sonarr #seriesList{border:0;border-radius:0;background:transparent;overflow:visible}.series-category{margin:0 0 18px;padding:0 0 2px;border:1px solid rgba(96,169,230,.16);border-radius:13px;background:linear-gradient(145deg,rgba(34,39,47,.82),rgba(25,27,31,.78));box-shadow:0 10px 28px rgba(0,0,0,.14);overflow:hidden}.series-category:last-child{margin-bottom:0}.series-category-heading{margin:0;padding:13px 16px;border:0;border-bottom:1px solid rgba(96,169,230,.16);border-radius:0;background:linear-gradient(100deg,rgba(54,74,98,.72),rgba(36,42,50,.7));box-shadow:inset 3px 0 0 #60a9e6}.series-category-heading h2{font-size:14px;letter-spacing:.02em}.series-category-heading span{padding:3px 8px;border:1px solid rgba(255,255,255,.1);border-radius:999px;background:rgba(10,14,20,.2)}.series-category>.series{margin:8px 10px 0;border-color:rgba(255,255,255,.08);border-radius:9px;background:rgba(31,33,38,.78);box-shadow:0 5px 15px rgba(0,0,0,.13)}.series-category>.series:last-child{margin-bottom:10px}.series-category>.series:hover{transform:none;border-color:rgba(96,169,230,.4);box-shadow:0 8px 22px rgba(0,0,0,.2)}.series-category>.series summary{position:relative!important;top:auto!important;z-index:1;min-height:42px;padding:10px 13px;background:rgba(42,45,51,.8);font-size:12px}.series-category>.series[open] summary{background:rgba(51,65,82,.88);border-bottom-color:rgba(96,169,230,.24)}.series-category>.series table{background:rgba(22,24,28,.42)}.series-category>.series th{padding:7px 10px;background:rgba(40,45,53,.86);font-size:10px;color:#9eb8ce;text-transform:uppercase;letter-spacing:.05em}.series-category>.series td{padding:8px 10px}.series-category .season-divider td{position:static!important;top:auto!important;padding:7px 13px;background:rgba(12,15,19,.72);color:#9da5b2;border-top:0}.missing-table{border:1px solid rgba(255,255,255,.1);border-radius:13px;background:rgba(28,30,35,.82);box-shadow:0 10px 30px rgba(0,0,0,.18);overflow:hidden}.missing-table>table{background:transparent}.missing-table>table th{padding:10px 12px;background:linear-gradient(100deg,#303741,#272b32);color:#b5c7d8;text-transform:uppercase;font-size:10px;letter-spacing:.06em}.missing-table>table td{padding:9px 12px}.missing-table>table tbody tr:hover{background:rgba(96,169,230,.08)}.missing-table .missing-series{margin:8px 10px!important;border:1px solid rgba(255,255,255,.09)!important;border-radius:10px!important;background:rgba(36,38,43,.78);box-shadow:0 5px 18px rgba(0,0,0,.14)}.missing-table .missing-series summary{position:relative!important;top:auto!important;z-index:1;min-height:44px;padding:11px 13px;background:rgba(47,45,45,.78);border-bottom:0}.missing-table .missing-series[open] summary{background:rgba(91,55,58,.78);border-bottom-color:rgba(255,130,130,.2)}.missing-table .missing-series table{background:rgba(22,24,28,.42)}.missing-table .missing-series th{padding:7px 10px;background:rgba(56,47,49,.84);font-size:10px;color:#d6afb2;text-transform:uppercase;letter-spacing:.05em}.missing-table .missing-series td{padding:8px 10px}.missing-table .missing-series tbody tr:hover{background:rgba(210,100,100,.08)}
|
||||
</style>
|
||||
<style>
|
||||
:root{--accent:#f4c430;--accent-blue:#60a9e6;--surface:rgba(35,37,42,.82);--surface-strong:#282b31;--border-soft:rgba(255,255,255,.09)}
|
||||
html,body{min-height:100%;background:#151619}body{min-height:100vh;background:radial-gradient(circle at 10% -15%,rgba(96,169,230,.12),transparent 33%),radial-gradient(circle at 90% 0%,rgba(244,196,48,.07),transparent 28%),#151619;background-attachment:fixed;letter-spacing:.01em}
|
||||
header{height:64px;background:rgba(31,33,38,.9);backdrop-filter:blur(18px);border-bottom:1px solid var(--border-soft);box-shadow:0 8px 30px rgba(0,0,0,.2);position:sticky;top:0;z-index:20}.logo{font-size:23px;text-shadow:0 0 18px rgba(244,196,48,.24)}.head-title{font-size:14px;letter-spacing:.01em}.head-sub{color:#9da5b2}
|
||||
main{max-width:1800px;margin:0 auto;padding:18px 20px 42px}.nav{gap:6px;margin:0 0 16px;padding:4px;border:1px solid var(--border-soft);border-radius:9px;background:rgba(31,33,38,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.nav button{padding:9px 14px;border-radius:6px;color:#aeb5c1}.nav button.active{background:linear-gradient(135deg,#3a3f48,#2d3139);box-shadow:inset 0 1px rgba(255,255,255,.08),0 4px 12px rgba(0,0,0,.18);color:#fff}.nav button.active::after{content:"";display:block;height:2px;margin:6px 2px -4px;border-radius:2px;background:var(--accent-blue)}
|
||||
.toolbar{padding:1px 10px;border:1px solid var(--border-soft);border-radius:8px;background:rgba(35,37,42,.72);box-shadow:0 8px 20px rgba(0,0,0,.12);gap:8px}.toolbar input,.toolbar select{height:30px;padding:5px 9px;background:#2b2e34;border-color:#474d57;transition:border-color .2s,box-shadow .2s,background .2s}.toolbar input:focus,.toolbar select:focus{outline:none;background:#30343b;border-color:var(--accent-blue);box-shadow:0 0 0 3px rgba(96,169,230,.14)}
|
||||
.view#sonarr{display:none}.view#sonarr.active{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:10px;align-items:start}.view#sonarr>.series-toolbar{grid-column:1;grid-row:1;height:34px;margin-bottom:0}.view#sonarr>.stats{grid-column:2;grid-row:1;height:34px;margin:0;align-self:stretch;align-items:stretch;flex-wrap:nowrap;white-space:nowrap}.view#sonarr>.stats>.stat{height:34px;display:flex;align-items:center}.view#sonarr>#seriesList{grid-column:1/-1;grid-row:2}.view#radarr{display:none}.view#radarr.active{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:10px;align-items:start}.view#radarr>.toolbar{grid-column:1;grid-row:1;height:34px;margin-bottom:0}.view#radarr>.stats{grid-column:2;grid-row:1;height:34px;margin:0;align-self:stretch;align-items:stretch;flex-wrap:nowrap;white-space:nowrap}.view#radarr>.stats>.stat{height:34px;display:flex;align-items:center}.view#radarr>.tablewrap{grid-column:1/-1;grid-row:2}
|
||||
.stats{gap:9px;margin:12px 0}.stat{background:linear-gradient(135deg,rgba(48,52,60,.9),rgba(34,36,41,.9));border:1px solid var(--border-soft);border-radius:8px;padding:7px 11px;box-shadow:0 5px 15px rgba(0,0,0,.12)}.stat:first-child{color:#fff;font-weight:700}.scan{color:#f4c430!important}
|
||||
.tablewrap{border:1px solid var(--border-soft);border-radius:10px;background:rgba(31,33,38,.84);box-shadow:0 14px 34px rgba(0,0,0,.2)}table{border-spacing:0}thead th{height:38px;background:linear-gradient(180deg,#30343b,#292c32);color:#bfc6d1;text-transform:uppercase;letter-spacing:.07em;font-size:10px}tbody td{height:42px;border-bottom-color:rgba(255,255,255,.075)}tbody tr{transition:background .2s,transform .2s}tbody tr:hover{background:rgba(96,169,230,.065);transform:translateX(1px)}.title{font-size:13px}.quality{box-shadow:0 0 12px rgba(21,148,71,.18)}
|
||||
#rescan{padding:9px 13px;background:linear-gradient(135deg,#343943,#292c32);border-color:#515865;box-shadow:0 5px 16px rgba(0,0,0,.18);transition:transform .2s,box-shadow .2s,border-color .2s}#rescan:hover{transform:translateY(-2px);border-color:var(--accent-blue);box-shadow:0 8px 20px rgba(0,0,0,.28)}
|
||||
.actions{display:flex;align-items:center;gap:7px}.lang-switch,.settings-button{height:36px;min-width:36px;padding:0 9px;background:rgba(48,52,60,.9);border-color:#515865;color:#d9dee7;font-weight:700}.settings-button{font-size:17px;padding:0}.lang-switch:hover,.settings-button:hover{border-color:var(--accent-blue);background:#363c47}.settings-backdrop{position:fixed;inset:0;z-index:200;display:grid;place-items:center;background:rgba(5,7,10,.68);backdrop-filter:blur(8px);animation:fadeIn .2s ease}.settings-backdrop.hidden{display:none}.settings-modal{width:min(440px,calc(100vw - 28px));padding:22px;border:1px solid #4a5362;border-radius:14px;background:linear-gradient(145deg,#292e36,#1f2228);box-shadow:0 24px 80px rgba(0,0,0,.55);animation:modalIn .28s cubic-bezier(.2,.8,.2,1)}.settings-head{display:flex;justify-content:space-between;gap:20px;margin-bottom:22px}.settings-head h2{margin:0 0 5px;font-size:19px}.settings-head p{margin:0;color:var(--muted);font-size:12px}.settings-close{padding:0;width:30px;height:30px;border:0;background:transparent;font-size:25px;color:#aeb5c1}.settings-modal label{display:block;margin:15px 0 7px;color:#cbd1db;font-size:12px;font-weight:700}.settings-modal select{width:100%;height:38px;background:#20242a;border-color:#505967}.settings-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:24px}.settings-actions button{padding:9px 14px}.settings-save{background:#176f40;border-color:#23965b;color:#e4ffef}.settings-save:hover{background:#1c8850}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes modalIn{from{opacity:0;transform:translateY(12px) scale(.97)}to{opacity:1;transform:none}}
|
||||
@media(max-width:760px){header{padding:0 12px}.head-sub{display:none}main{padding:12px}.view#radarr.active,.view#sonarr.active{display:block}.view#radarr>.toolbar,.view#sonarr>.series-toolbar{height:auto;margin-bottom:10px}.view#radarr>.stats,.view#sonarr>.stats{height:auto;align-items:initial;flex-wrap:wrap}.view#radarr>.stats>.stat,.view#sonarr>.stats>.stat{height:auto}.toolbar{grid-template-columns:1fr}.actions{margin-left:auto}.nav{overflow-x:auto}.nav button{white-space:nowrap}.tablewrap{overflow-x:auto}table{min-width:900px}}
|
||||
main{min-height:calc(100vh - 64px)}
|
||||
.download-table{display:grid;gap:14px;border:0;background:transparent;box-shadow:none;overflow:visible}.download-section{border:1px solid rgba(255,255,255,.09);border-radius:12px;overflow:hidden;background:rgba(31,33,38,.84);box-shadow:0 12px 28px rgba(0,0,0,.18)}.download-section-head{display:flex;align-items:center;justify-content:space-between;padding:13px 15px;border-bottom:1px solid rgba(255,255,255,.08);background:linear-gradient(100deg,rgba(46,56,69,.9),rgba(36,39,45,.9))}.download-section-head h3{margin:0;color:#e8eef5;font-size:14px}.download-section-head span{min-width:24px;padding:3px 8px;border-radius:999px;background:rgba(96,169,230,.16);color:#9ed2f5;font-size:11px;text-align:center}.download-secondary .download-section-head{background:linear-gradient(100deg,rgba(53,47,47,.9),rgba(38,37,41,.9))}.download-secondary .download-section-head span{background:rgba(244,196,48,.15);color:#f4d267}.download-section table{background:transparent}.download-section tbody tr:hover{background:rgba(96,169,230,.07)}
|
||||
</style>
|
||||
<script>
|
||||
document.querySelectorAll('details.series tbody').forEach(tbody=>{let currentSeason=null;[...tbody.querySelectorAll('tr')].forEach(row=>{if(row.classList.contains('season-divider'))return;const match=row.querySelector('.title')?.textContent.match(/^S(\d{2})E\d{2}/i);if(!match||match[1]===currentSeason)return;currentSeason=match[1];const divider=document.createElement('tr');divider.className='season-divider';divider.innerHTML=`<td colspan="7">Staffel ${Number(currentSeason)}</td>`;tbody.insertBefore(divider,row)})});
|
||||
document.querySelectorAll('details.series').forEach(series=>{const summary=series.querySelector('summary');if(!summary)return;summary.title='Klicken zum Auf- oder Zuklappen';const observer=new IntersectionObserver(([entry])=>series.classList.toggle('series-current',!entry.isIntersecting&&series.open),{threshold:0,rootMargin:'-58px 0px 0px 0px'});observer.observe(summary)});
|
||||
</script>
|
||||
<style>
|
||||
.downloading-badge{background:#713c9e!important;border-color:#9257c2!important;color:#f2e5ff!important}.state.downloading{background:#713c9e;color:#f2e5ff}
|
||||
header{z-index:100!important}
|
||||
.downloading-state{background:#713c9e!important;color:#f2e5ff!important}
|
||||
</style>
|
||||
<script>
|
||||
function normalizeDownloadText(value){return String(value||'').toLowerCase().replace(/[._()[\]{}'\-·]/g,' ').replace(/\s+/g,' ').trim()}
|
||||
function markDownloading(items){
|
||||
const downloads=(items||[]).filter(item=>!item.completed).map(item=>normalizeDownloadText(item.title));
|
||||
document.querySelectorAll('#movies tbody tr,#sonarr tbody tr').forEach(row=>{
|
||||
if(row.dataset.downloadMarked==='1'){
|
||||
row.children[1].innerHTML='<span class="badge">Keine Datei</span>';
|
||||
row.children[2].innerHTML='<span class="state bad">Fehler</span>';
|
||||
row.dataset.downloadMarked='0';
|
||||
}
|
||||
const details=row.closest('details.series');
|
||||
const seriesName=details?normalizeDownloadText(details.querySelector('summary')?.textContent.split('(')[0]):'';
|
||||
const rowName=normalizeDownloadText(row.querySelector('.title')?.textContent);
|
||||
const rowTokens=rowName.split(' ').filter(token=>token.length>2);
|
||||
const isMatch=downloads.some(download=>download.includes(rowName)||(seriesName&&download.includes(seriesName)&&download.includes(rowName.match(/s\d{2}e\d{2}/)?.[0]||''))||(rowTokens.length>=3&&rowTokens.filter(token=>download.includes(token)).length>=Math.ceil(rowTokens.length*.75)));
|
||||
if(!rowName||!isMatch)return;
|
||||
const quality=row.children[1], state=row.children[2];
|
||||
if(quality.textContent.includes('Keine Datei')||row.dataset.downloadMarked==='1'){
|
||||
quality.innerHTML='<span class="badge downloading-badge">Downloading</span>';
|
||||
state.innerHTML='<span class="state downloading-state">Downloading</span>';
|
||||
row.dataset.downloadMarked='1';
|
||||
}
|
||||
});
|
||||
}
|
||||
const originalRenderDownloads=renderDownloads;
|
||||
renderDownloads=function(data){originalRenderDownloads(data);const rows=[...downloadBody.querySelectorAll('tr')],activeRows=[],otherRows=[];rows.forEach(row=>{const status=(row.querySelector('.state')?.textContent||'').trim().toLowerCase();if(status==='downloading')activeRows.push(row);else if(row.querySelector('.download-title'))otherRows.push(row)});downloadBody.innerHTML='';otherDownloadBody.innerHTML='';activeRows.forEach(row=>downloadBody.appendChild(row));otherRows.forEach(row=>otherDownloadBody.appendChild(row));if(!activeRows.length)downloadBody.innerHTML='<tr><td colspan="7" class="muted">Keine aktiven Downloads</td></tr>';if(!otherRows.length)otherDownloadBody.innerHTML='<tr><td colspan="7" class="muted">Keine weiteren Queue-Einträge</td></tr>';document.querySelector('#activeDownloadSectionCount').textContent=activeRows.length;document.querySelector('#otherDownloadSectionCount').textContent=otherRows.length;markDownloading(data.items);const navCount=document.querySelector('#downloadNavCount'),active=Number(data.active_count)||0;if(navCount){navCount.textContent=active;navCount.classList.toggle('hidden',active===0)}};
|
||||
if(downloadBody)fetchDownloads();
|
||||
</script>
|
||||
<script>
|
||||
const uiTranslations={de:{film:'Film',quality:'Qualität',status:'Status',audio:'Audio',subs:'Untertitel',video:'Video',size:'Größe',episode:'Episode',allAudio:'Alle Audio-Sprachen',allSubs:'Alle Untertitel',allStatus:'Alle Status',searchMovie:'Film suchen …',searchSeries:'Serie suchen …'},en:{film:'Movie',quality:'Quality',status:'Status',audio:'Audio',subs:'Subtitles',video:'Video',size:'Size',episode:'Episode',allAudio:'All audio languages',allSubs:'All subtitles',allStatus:'All statuses',searchMovie:'Search movies …',searchSeries:'Search series …'}};
|
||||
let currentUiLanguage=localStorage.getItem('dashboard-language')||'de';
|
||||
function translateLayout(language){
|
||||
currentUiLanguage=language;localStorage.setItem('dashboard-language',language);document.documentElement.lang=language;const t=uiTranslations[language];
|
||||
document.querySelectorAll('[data-de][data-en]').forEach(node=>{node.textContent=node.dataset[language]});
|
||||
document.querySelector('#languageToggle').textContent=language==='de'?'EN':'DE';
|
||||
const navLabels={dashboard:['▦ Dashboard','▦ Dashboard'],radarr:['RADARR · Filme','RADARR · Movies'],sonarr:['SONARR · Serien','SONARR · Series'],missing:['⚠ Ohne Deutsch','⚠ Without German'],downloads:['SABNZBD · Downloads','SABNZBD · Downloads'],datastore:['Datastore','Datastore'],graphics:['Graphics','Graphics']};document.querySelectorAll('.nav button').forEach(button=>{const labels=navLabels[button.dataset.view];if(labels)button.childNodes[0].textContent=labels[language==='de'?0:1]});
|
||||
const searchInput=document.querySelector('#search');if(searchInput)searchInput.placeholder=t.searchMovie;const seriesSearchInput=document.querySelector('#seriesSearch');if(seriesSearchInput)seriesSearchInput.placeholder=t.searchSeries;
|
||||
const filterOptions=[['#audioFilter',t.allAudio],['#subFilter',t.allSubs],['#stateFilter',t.allStatus]];filterOptions.forEach(([selector,label])=>{const first=document.querySelector(`${selector} option:first-child`);if(first)first.textContent=label});
|
||||
document.querySelectorAll('#movies thead th').forEach((th,i)=>{th.textContent=[t.film,t.quality,t.status,t.audio,t.subs,t.video,t.size][i]||th.textContent});
|
||||
document.querySelectorAll('#seriesList thead th').forEach((th,i)=>{th.textContent=[t.episode,t.quality,t.status,t.audio,t.subs,t.video,t.size][i]||th.textContent});
|
||||
document.querySelectorAll('#downloadsTable thead th').forEach((th,i)=>{th.textContent=(language==='de'?['Download','Status','Fortschritt','Größe / Rest','Restzeit','Quelle','Sprache']:['Download','Status','Progress','Size / Remaining','Time left','Source','Language'])[i]||th.textContent});
|
||||
}
|
||||
function highlightPreferences(){const audioPreference=localStorage.getItem('preferred-audio')||'';document.querySelectorAll('#movies tbody tr td:nth-child(4) .badge,#sonarr tbody tr td:nth-child(4) .badge').forEach(badge=>badge.classList.toggle('audio-preferred',!!audioPreference&&badge.textContent.includes(audioPreference)))}
|
||||
function loadPreferences(){const audioPreference=localStorage.getItem('preferred-audio')||'';const subsPreference=localStorage.getItem('preferred-subs')||'';const audioSelect=document.querySelector('#preferredAudio'),subsSelect=document.querySelector('#preferredSubs');if(audioSelect)audioSelect.value=audioPreference;if(subsSelect)subsSelect.value=subsPreference;const audioFilter=document.querySelector('#audioFilter'),subFilter=document.querySelector('#subFilter');if(audioFilter)audioFilter.value=audioPreference;if(subFilter)subFilter.value=subsPreference;if(typeof apply==='function')apply();highlightPreferences()}
|
||||
translateLayout(currentUiLanguage);loadPreferences();
|
||||
const serviceIconSvg={radarr:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2" fill="currentColor"/><path d="M12 5a7 7 0 0 1 6 3M12 12l6-5"/></svg>',sonarr:'<svg viewBox="0 0 24 24"><path d="M4 12h3M17 12h3M7 8a6 6 0 0 1 10 0M7 16a6 6 0 0 0 10 0M10 12h4"/><circle cx="12" cy="12" r="2" fill="currentColor"/></svg>',sabnzbd:'<svg viewBox="0 0 24 24"><path d="M12 4v11M8 11l4 4 4-4M5 19h14"/></svg>',jellyfin:'<svg viewBox="0 0 24 24"><path d="M7 4h10l4 8-4 8H7l-4-8 4-8Z"/><path d="m10 8 6 4-6 4V8Z" fill="currentColor"/></svg>',seerr:'<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6"/><path d="m15 15 5 5M8 10h5"/></svg>',prowlarr:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><path d="m12 6 2 6-2 6-2-6 2-6ZM6 12h12"/></svg>',easynews:'<svg viewBox="0 0 24 24"><path d="M6 18h12a3 3 0 0 0 .5-6A6.5 6.5 0 0 0 6 10a4 4 0 0 0 0 8Z"/></svg>',qbittorrent:'<svg viewBox="0 0 24 24"><path d="m5 7 5-3 5 3-5 3-5-3ZM9 14l5-3 5 3-5 3-5-3ZM5 17l5-3 5 3-5 3-5-3Z"/></svg>',bazarr:'<svg viewBox="0 0 24 24"><path d="M5 6h14M5 12h9M5 18h14"/><path d="m17 10 2 2-2 2"/></svg>',lingarr:'<svg viewBox="0 0 24 24"><path d="M5 6h14v10H9l-4 3V6Z"/><path d="M9 10h6M9 13h4"/></svg>',libretranslate:'<svg viewBox="0 0 24 24"><path d="M5 6h8M9 4v2M6 6c1 5 4 8 8 10M6 16c3-1 5-3 7-6M14 6h5M16 4v2M14 18h7M17 10l-3 8M18 15h4"/></svg>',sonarr_prowlarr_proxy:'<svg viewBox="0 0 24 24"><path d="M5 8h8M11 5l3 3-3 3M19 16h-8M13 13l-3 3 3 3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="16" r="2"/></svg>',radarr_prowlarr_proxy:'<svg viewBox="0 0 24 24"><path d="M5 8h8M11 5l3 3-3 3M19 16h-8M13 13l-3 3 3 3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="16" r="2"/></svg>',dashboard:'<svg viewBox="0 0 24 24"><path d="m4 11 8-7 8 7v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-8Z"/><path d="M9 20v-6h6v6"/></svg>'};function upgradeServiceIcons(){document.querySelectorAll('.service-logo[class*="service-icon-"]').forEach(icon=>{const key=[...icon.classList].find(name=>name.startsWith('service-icon-'))?.slice(13);if(serviceIconSvg[key]&&icon.dataset.iconKey!==key){icon.innerHTML=serviceIconSvg[key];icon.dataset.iconKey=key}})}const serviceIconObserver=new MutationObserver(upgradeServiceIcons);const serviceIconList=document.querySelector('.service-list');if(serviceIconList)serviceIconObserver.observe(serviceIconList,{childList:true,subtree:true});upgradeServiceIcons();
|
||||
document.querySelector('#languageToggle').addEventListener('click',()=>translateLayout(currentUiLanguage==='de'?'en':'de'));
|
||||
const header=document.querySelector('header'),headerActions=document.querySelector('.actions'),mainNav=document.querySelector('.nav');if(header&&headerActions&&mainNav){mainNav.classList.add('header-nav');header.insertBefore(mainNav,headerActions);const headerStyle=document.createElement('style');headerStyle.textContent='.head-title,.head-sub{display:none}.header-nav{display:flex;align-items:center;gap:4px;margin:0 0 0 22px!important;padding:3px!important;border:0!important;background:transparent!important;box-shadow:none!important;overflow:visible}.header-nav button{padding:8px 11px;font-size:12px}.header-nav .nav-count{font-size:10px}header{gap:0}.brand{flex:0 0 auto}@media(max-width:850px){.header-nav{margin-left:10px!important;overflow-x:auto;max-width:calc(100vw - 250px);scrollbar-width:none}.header-nav::-webkit-scrollbar{display:none}.header-nav button{white-space:nowrap;padding:7px 9px;font-size:11px}}@media(max-width:560px){.header-nav{max-width:calc(100vw - 135px)}.header-nav button{font-size:0;padding:8px}.header-nav button[data-view="dashboard"]{font-size:0}.header-nav button[data-view="dashboard"]::before{content:"▦";font-size:16px}.header-nav button[data-view="radarr"]::before{content:"R";font-size:14px;font-weight:800}.header-nav button[data-view="sonarr"]::before{content:"S";font-size:14px;font-weight:800}.header-nav button[data-view="missing"]::before{content:"⚠";font-size:14px}.header-nav button[data-view="downloads"]::before{content:"↓";font-size:16px}}';document.head.appendChild(headerStyle)}
|
||||
const officialServiceIcons={radarr:'https://cdn.simpleicons.org/radarr/CC7B19',sonarr:'https://cdn.simpleicons.org/sonarr/35C5F4',sabnzbd:'https://api.iconify.design/mdi:download-box.svg?color=%23F5A623',jellyfin:'https://cdn.simpleicons.org/jellyfin/AA5CC3',seerr:'https://api.iconify.design/mdi:movie-search.svg?color=%2373CFF8',prowlarr:'https://api.iconify.design/mdi:radar.svg?color=%235B6BBF',easynews:'https://api.iconify.design/mdi:cloud-download.svg?color=%23F28C28',qbittorrent:'https://cdn.simpleicons.org/qbittorrent/2F81C7',bazarr:'https://api.iconify.design/mdi:subtitles.svg?color=%2344A5B5',lingarr:'https://api.iconify.design/mdi:forum.svg?color=%238B5CF6',libretranslate:'https://cdn.simpleicons.org/libretranslate/22C55E'};Object.entries(officialServiceIcons).forEach(([key,url])=>{serviceIconSvg[key]=`<img src="${url}" alt="" loading="lazy" referrerpolicy="no-referrer">`});upgradeServiceIcons();
|
||||
document.querySelectorAll('.service-logo svg').forEach(svg=>{svg.style.width='19px';svg.style.height='19px';svg.style.fill='none';svg.style.stroke='currentColor';svg.style.strokeWidth='1.8';svg.style.strokeLinecap='round';svg.style.strokeLinejoin='round'});
|
||||
document.querySelectorAll('.service-logo img').forEach(img=>{img.style.width='20px';img.style.height='20px';img.style.objectFit='contain'});
|
||||
setInterval(()=>{document.querySelectorAll('.service-logo svg').forEach(svg=>{svg.style.width='19px';svg.style.height='19px';svg.style.fill='none';svg.style.stroke='currentColor';svg.style.strokeWidth='1.8';svg.style.strokeLinecap='round';svg.style.strokeLinejoin='round'});document.querySelectorAll('.service-logo img').forEach(img=>{img.style.width='20px';img.style.height='20px';img.style.objectFit='contain'})},500);
|
||||
const settingsBackdrop=document.querySelector('#settingsBackdrop');const closeSettings=()=>settingsBackdrop.classList.add('hidden');document.querySelector('#settingsOpen').addEventListener('click',()=>{loadPreferences();settingsBackdrop.classList.remove('hidden')});document.querySelector('#settingsClose').addEventListener('click',closeSettings);document.querySelector('#settingsCancel').addEventListener('click',closeSettings);settingsBackdrop.addEventListener('click',event=>{if(event.target===settingsBackdrop)closeSettings()});document.querySelector('#settingsSave').addEventListener('click',()=>{localStorage.setItem('preferred-audio',document.querySelector('#preferredAudio').value);localStorage.setItem('preferred-subs',document.querySelector('#preferredSubs').value);loadPreferences();closeSettings()});
|
||||
</script>
|
||||
<style>.nav-icon{width:15px;height:15px;margin-right:5px;vertical-align:-3px;object-fit:contain}.nav-tab-icon{width:15px;height:15px;margin-right:6px;vertical-align:-3px;object-fit:contain}.storage-icon img{width:20px;height:20px;object-fit:contain}.raid-status-side{display:flex;flex-direction:column;align-items:flex-end;gap:4px}.raid-status-side small{color:#8d9aaa;font-size:10px;white-space:nowrap}.raid-disks{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.raid-disk{padding:7px 9px}.raid-disk small{margin-top:1px}.raid-smart{opacity:.85}.series-category,.missing-table{overflow:visible!important}.series-category>.series summary,.missing-table .missing-series summary{position:sticky!important;top:64px!important;z-index:30!important}.series-category>.series[open] summary,.missing-table .missing-series[open] summary{box-shadow:0 4px 14px rgba(0,0,0,.25)}@media(max-width:700px){.raid-disks{grid-template-columns:1fr}}</style>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#101113"><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}"><link rel="apple-touch-icon" href="{{ url_for('static', filename='media-mark-192.png') }}"><link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='media-mark.svg') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='dashboard.css') }}?v=8"><title>Media Max</title>
|
||||
<script src="{{ url_for('static', filename='dashboard.js') }}?v=8" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<svg class="svg-definitions" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<symbol id="icon-dashboard" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
|
||||
<symbol id="icon-movie" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M8 4l3 5m4-5 3 5"/></symbol>
|
||||
<symbol id="icon-series" viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="14" rx="2"/><path d="m8 2 4 4 4-4M8 23h8"/></symbol>
|
||||
<symbol id="icon-alert" viewBox="0 0 24 24"><path d="m10.3 4-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3l-8-14a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4m0 4h.01"/></symbol>
|
||||
<symbol id="icon-download" viewBox="0 0 24 24"><path d="M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5"/></symbol>
|
||||
<symbol id="icon-disk" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="12" cy="10" r="4"/><path d="m12 10 5 5M6 18h.01"/></symbol>
|
||||
<symbol id="icon-chip" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 1v4m6-4v4M9 19v4m6-4v4M1 9h4m-4 6h4m14-6h4m-4 6h4"/></symbol>
|
||||
<symbol id="icon-settings" viewBox="0 0 24 24"><path d="m9 3-1 3-3 1-2 5 2 5 3 1 1 3h6l1-3 3-1 2-5-2-5-3-1-1-3Z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="icon-search" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/></symbol>
|
||||
<symbol id="icon-refresh" viewBox="0 0 24 24"><path d="M20 8a8 8 0 1 0 0 8M20 3v5h-5"/></symbol>
|
||||
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="icon-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
|
||||
<symbol id="icon-activity" viewBox="0 0 24 24"><path d="M2 12h5l3-8 4 16 3-8h5"/></symbol>
|
||||
<symbol id="icon-server" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.01M7 17.5h.01M12 6.5h5M12 17.5h5"/></symbol>
|
||||
<symbol id="icon-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
|
||||
<symbol id="icon-language" viewBox="0 0 24 24"><path d="M3 5h12M9 2v3M5 5c1 5 4 8 8 10M4 15c4-2 7-5 8-10m2 16 4-10 4 10m-6-4h4"/></symbol>
|
||||
</svg>
|
||||
<a class="skip-link" href="#main">Zum Inhalt</a>
|
||||
<button id="sidebarShade" class="sidebar-shade" aria-label="Navigation schließen" tabindex="-1"></button>
|
||||
<aside class="sidebar" id="sidebar" aria-label="Hauptnavigation">
|
||||
<a class="brand" href="/">{{ icon('activity', 'brand-icon') }}<span>Media<span class="brand-accent"> Max</span></span></a>
|
||||
<div class="workspace">{{ icon('server') }}<div><strong>Media Stack</strong><small>{{ tr('Bibliothek & Monitoring', 'Library & monitoring') }}</small></div><span class="status-dot"></span></div>
|
||||
<nav class="nav">
|
||||
<div class="nav-label">{{ tr('Übersicht', 'Overview') }}</div>
|
||||
{{ nav_button('dashboard', 'dashboard', 'Dashboard', 'Dashboard') }}
|
||||
<div class="nav-label">{{ tr('Bibliothek', 'Library') }}</div>
|
||||
{{ nav_button('radarr', 'movie', 'Filme', 'Movies') }}
|
||||
{% if sonarr_enabled %}{{ nav_button('sonarr', 'series', 'Serien', 'Series') }}{% endif %}
|
||||
{{ nav_button('missing', 'language', 'Ohne Deutsch', 'Without German', 'missingNavCount') }}
|
||||
{% if sab_enabled %}{{ nav_button('downloads', 'download', 'Downloads', 'Downloads', 'downloadNavCount') }}{% endif %}
|
||||
<div class="nav-label">{{ tr('System', 'System') }}</div>
|
||||
{{ nav_button('datastore', 'disk', 'Speicher & RAID', 'Storage & RAID') }}
|
||||
{{ nav_button('graphics', 'chip', 'Graphics', 'Graphics') }}
|
||||
</nav>
|
||||
<div class="sidebar-bottom"><button id="settingsOpen">{{ icon('settings') }}{{ tr('Einstellungen', 'Settings') }}</button><div class="sidebar-footer"><span class="status-dot"></span> Media Max <span>Self-hosted</span></div></div>
|
||||
</aside>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<button id="menuToggle" class="icon-button menu-toggle" aria-label="Navigation öffnen" aria-controls="sidebar" aria-expanded="false">{{ icon('menu') }}</button>
|
||||
<div class="breadcrumb">Media Max <span>/</span> <strong id="pageTitle">Dashboard</strong></div>
|
||||
<div class="actions">
|
||||
<button id="languageToggle" class="icon-button" aria-label="Sprache wechseln">EN</button>
|
||||
<div class="scan-control">
|
||||
<select id="scanSource" aria-label="Scan-Quelle">
|
||||
<option value="all" data-de="Alle Quellen" data-en="All sources">Alle Quellen</option>
|
||||
<option value="radarr">Radarr</option>
|
||||
{% if sonarr_enabled %}<option value="sonarr">Sonarr</option>{% endif %}
|
||||
<option value="missing" data-de="Ohne Deutsch" data-en="Without German">Ohne Deutsch</option>
|
||||
</select>
|
||||
<button id="rescan" class="primary-button">{{ icon('refresh') }}{{ tr('Neu scannen', 'Rescan') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main">
|
||||
{% if error %}<div class="banner">{{ icon('alert') }}{{ error }}</div>{% endif %}
|
||||
{% if not library_ready %}<div class="notice">{{ tr('Bibliothek wird synchronisiert. Diese Ansicht aktualisiert sich automatisch.', 'Library is syncing. This view will update automatically.') }}</div>{% endif %}
|
||||
<div id="actionError" class="banner hidden" role="alert"></div>
|
||||
<section id="dashboard" class="view active">
|
||||
<div class="page-heading"><div><h1>{{ tr('Dashboard', 'Dashboard') }}</h1><p>{{ tr('Deine Medien. Deine Sprachen. Dein System.', 'Your media. Your languages. Your system.') }}</p></div><span class="live-label"><span class="status-dot"></span>{{ tr('Live-Monitoring', 'Live monitoring') }}</span></div>
|
||||
<div class="summary-grid">
|
||||
<button class="summary-card" data-go="radarr"><span class="summary-icon">{{ icon('movie') }}</span><div><strong>{{ rows|length }}</strong><span>{{ tr('Filme', 'Movies') }}</span><small>Radarr · {{ dashboard_stats.movie_files }} {{ tr('vorhanden', 'available') }}</small></div></button>
|
||||
<button class="summary-card" {% if sonarr_enabled %}data-go="sonarr"{% endif %}><span class="summary-icon purple">{{ icon('series') }}</span><div><strong>{{ series|length }}</strong><span>{{ tr('Serien', 'Series') }}</span><small>{{ dashboard_stats.episode_files }} {{ tr('Episoden vorhanden', 'episodes available') }}</small></div></button>
|
||||
<button class="summary-card" data-go="missing"><span class="summary-icon amber">{{ icon('language') }}</span><div><strong id="dashboardMissing">0</strong><span>{{ tr('Ohne Deutsch', 'Without German') }}</span><small>{{ tr('Audio & Untertitel prüfen', 'Review audio & subtitles') }}</small></div></button>
|
||||
<button class="summary-card" {% if sab_enabled %}data-go="downloads"{% endif %}><span class="summary-icon green">{{ icon('download') }}</span><div><strong id="dashboardDownloads">{{ '—' if sab_enabled else '0' }}</strong><span>{{ tr('Downloads', 'Downloads') }}</span><small id="dashboardRemaining">{{ 'SABnzbd …' if sab_enabled else 'SABnzbd nicht konfiguriert' }}</small></div></button>
|
||||
</div>
|
||||
<div class="section-title">{{ icon('movie') }}<h2>{{ tr('Bibliotheksübersicht', 'Library overview') }}</h2></div>
|
||||
<div class="dashboard-grid">
|
||||
<article class="panel library-panel"><div class="panel-heading"><h3>{{ tr('Medienbestand', 'Media collection') }}</h3><span class="muted">Radarr / Sonarr</span></div>
|
||||
{% for de, en, files, total, missing in [('Filme', 'Movies', dashboard_stats.movie_files, dashboard_stats.movie_total, dashboard_stats.movie_missing), ('Episoden', 'Episodes', dashboard_stats.episode_files, dashboard_stats.episode_total, dashboard_stats.episode_missing)] %}
|
||||
<div class="coverage-row"><div class="metric-line"><span>{{ tr(de, en) }}</span><strong>{{ files }} <span class="muted">/ {{ total }}</span></strong></div><div class="track"><span style="width:{{ files / total * 100 if total else 0 }}%"></span></div><small class="muted">{{ missing }} {{ tr('ohne Datei', 'without a file') }}</small></div>{% endfor %}
|
||||
<div class="library-footer">{{ icon('refresh') }}<span>{{ tr('Automatischer Sync', 'Automatic sync') }} · {{ (sync_interval_seconds / 60)|round|int if sync_interval_seconds else '—' }} {{ tr('Minuten', 'minutes') }}</span></div>
|
||||
</article>
|
||||
<article class="panel"><div class="panel-heading"><h3>{{ tr('Spracherkennung', 'Language detection') }}</h3>{{ icon('language') }}</div><div class="language-metrics"><div><strong class="text-green">{{ dashboard_stats.german_tracks }}</strong><small>{{ tr('Mit Deutsch', 'With German') }}</small></div><div><strong class="text-amber">{{ dashboard_stats.unknown_audio }}</strong><small>{{ tr('Audio unbekannt', 'Unknown audio') }}</small></div><div><strong>{{ dashboard_stats.pending }}</strong><small>{{ tr('Scan ausstehend', 'Scan pending') }}</small></div><div><strong class="text-red">{{ dashboard_stats.scan_errors }}</strong><small>{{ tr('Scanfehler', 'Scan errors') }}</small></div></div>
|
||||
<p class="panel-note">{{ tr('Deutsch im Audio oder in Untertiteln zählt als verfügbar. Nicht beschriftete Spuren werden nicht geraten.', 'German audio or subtitles count as available. Unlabelled tracks are not guessed.') }}</p>
|
||||
{% if language_review %}<div class="review-list">{% for r in language_review %}<div><span class="title" title="{{ r.title }}">{{ r.title }}</span><span class="badge unknown">{{ 'Scanfehler' if r.error else 'Unbekannt' }}</span></div>{% endfor %}</div>{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
<div class="section-title">{{ icon('activity') }}<h2>{{ tr('System & Dienste', 'System & services') }}</h2><span id="systemUpdated" class="muted">—</span></div>
|
||||
<div class="dashboard-grid system-grid">
|
||||
<article class="panel"><div class="panel-heading"><h3>{{ tr('Systemauslastung', 'System resources') }}</h3>{{ icon('chip') }}</div><div class="system-meters"><div><div class="metric-line"><span>CPU</span><strong id="cpuValue">—</strong></div><div class="track"><span id="cpuBar"></span></div></div><div><div class="metric-line"><span>RAM</span><strong id="ramValue">—</strong></div><div class="track purple-track"><span id="ramBar"></span></div><small id="ramDetail" class="muted">—</small></div></div><div id="systemDetails" class="system-details"></div><div id="diskList" class="disk-list"></div></article>
|
||||
<article class="panel"><div class="panel-heading"><h3>{{ tr('Verbundene Dienste', 'Connected services') }}</h3><span id="serviceSummary" class="badge">—</span></div><div id="serviceList" class="service-list"><div class="empty-state">{{ tr('Dienste werden geprüft …', 'Checking services …') }}</div></div></article>
|
||||
</div>
|
||||
</section>
|
||||
<section id="radarr" class="view"><div class="page-heading"><div><h1>{{ tr('Filme', 'Movies') }}</h1><p>{{ tr('Qualität, Audio und Untertitel deiner Radarr-Bibliothek.', 'Quality, audio and subtitles in your Radarr library.') }}</p></div><span class="source-label">Radarr</span></div>{{ filters('movie') }}<div class="stats"><span id="movieCount"></span><span id="radarrStatus" class="scan-status">—</span></div><div class="panel table-panel">{{ media_table(rows, 'movies') }}</div><p class="metadata-note">* {{ tr('Sprache aus Radarr/Sonarr-Dateimetadaten, nicht direkt in der Datei bestätigt.', 'Language from Radarr/Sonarr file metadata, not directly confirmed in the file.') }}</p><div id="movieNoResults" class="empty-state hidden">{{ tr('Keine Filme für diese Filter.', 'No movies match these filters.') }}</div></section>
|
||||
{% if sonarr_enabled %}<section id="sonarr" class="view"><div class="page-heading"><div><h1>{{ tr('Serien', 'Series') }}</h1><p>{{ tr('Deine Serien, nach Kategorien und Staffeln geordnet.', 'Your shows, grouped by category and season.') }}</p></div><span class="source-label">Sonarr</span></div>{{ filters('series', true) }}<div class="stats"><span id="seriesCount"></span><span id="sonarrStatus" class="scan-status">—</span></div><div id="seriesList">{% for category in series_categories %}{% if category.series %}<section class="series-category"><div class="category-heading"><h2>{{ category.label }}</h2><span class="muted">{{ category.series|length }}</span></div>{% for group in category.series %}<details class="series" data-title="{{ group.title|lower }}"><summary>{{ icon('series') }}<strong>{{ group.title }}</strong><span class="muted">{{ group.year or '' }} · {{ group.episodes|length }} {{ tr('Episoden', 'episodes') }}</span></summary>{{ media_table(group.episodes, episode=true) }}</details>{% endfor %}</section>{% endif %}{% endfor %}</div><p class="metadata-note">* {{ tr('Sprache aus Radarr/Sonarr-Dateimetadaten, nicht direkt in der Datei bestätigt.', 'Language from Radarr/Sonarr file metadata, not directly confirmed in the file.') }}</p><div id="seriesNoResults" class="empty-state hidden">{{ tr('Keine Serien für diese Filter.', 'No series match these filters.') }}</div></section>{% endif %}
|
||||
<section id="missing" class="view"><div class="page-heading"><div><h1>{{ tr('Ohne Deutsch', 'Without German') }}</h1><p>{{ tr('Keine deutsche Audio- oder Untertitelspur erkannt. Unbekannte Spuren bitte prüfen.', 'No German audio or subtitles detected. Please review unknown tracks.') }}</p></div>{{ icon('language') }}</div><div class="stats"><span id="missingCount">—</span><span id="missingStatus" class="scan-status">—</span><span id="seasonZeroHint" class="muted"></span></div><div id="missingList">{% if missing_german_movies %}<h2 class="list-heading">{{ tr('Filme', 'Movies') }}</h2><div class="panel table-panel">{{ media_table(missing_german_movies, 'missingMovies', review=true) }}</div>{% endif %}{% if missing_german_series %}<h2 class="list-heading">{{ tr('Serien', 'Series') }}</h2>{% for group in missing_german_series %}<details class="series missing-series"><summary>{{ icon('series') }}<strong>{{ group.title }}</strong><span class="muted">{{ group.episodes|length }} {{ tr('Episoden', 'episodes') }}</span></summary>{{ media_table(group.episodes, episode=true, review=true) }}</details>{% endfor %}{% endif %}</div><div id="missingEmpty" class="panel empty-state hidden">{{ icon('check') }}{{ tr('Keine Einträge ohne Deutsch in der aktuellen Ansicht.', 'No entries without German in the current view.') }}</div></section>
|
||||
{% if sab_enabled %}<section id="downloads" class="view"><div class="page-heading"><div><h1>{{ tr('Downloads', 'Downloads') }}</h1><p>{{ tr('Aktuelle Queue und zuletzt abgeschlossene Downloads.', 'Current queue and recently completed downloads.') }}</p></div><span id="sabQueueStatus" class="source-label">SABnzbd</span></div><div class="summary-grid"><article class="summary-card"><span class="summary-icon">{{ icon('download') }}</span><div><strong id="sabRemaining">—</strong><span>{{ tr('Noch zu laden', 'Remaining') }}</span></div></article><article class="summary-card"><span class="summary-icon">{{ icon('movie') }}</span><div><strong id="sabActive">—</strong><span>{{ tr('Queue-Einträge', 'Queue entries') }}</span></div></article><article class="summary-card"><span class="summary-icon">{{ icon('activity') }}</span><div><strong id="sabSpeed">—</strong><span>{{ tr('Geschwindigkeit', 'Speed') }}</span></div></article><article class="summary-card"><span class="summary-icon green">{{ icon('check') }}</span><div><strong id="sabHistory">—</strong><span>{{ tr('Im Verlauf', 'In history') }}</span></div></article></div><div id="downloadError" class="banner hidden"></div>{% for id, de, en in [('activeDownloads', 'Aktive Downloads', 'Active downloads'), ('otherDownloads', 'Queue & Verlauf', 'Queue & history')] %}<div class="section-title">{{ icon('download') }}<h2>{{ tr(de, en) }}</h2><span id="{{ id }}Count" class="badge">0</span></div><div class="panel table-panel table-scroll"><table class="download-table"><thead><tr><th class="col-title">Download</th><th>Status</th><th>{{ tr('Fortschritt', 'Progress') }}</th><th>{{ tr('Größe / Rest', 'Size / remaining') }}</th><th>{{ tr('Restzeit', 'Time left') }}</th><th>{{ tr('Quelle', 'Source') }}</th><th>{{ tr('Sprache', 'Language') }}</th></tr></thead><tbody id="{{ id }}Body"></tbody></table></div>{% endfor %}</section>{% endif %}
|
||||
<section id="datastore" class="view"><div class="page-heading"><div><h1>{{ tr('Speicher & RAID', 'Storage & RAID') }}</h1><p>{{ tr('Speicherbelegung, Medienkategorien und Zustand deiner Laufwerke.', 'Storage usage, media categories and drive health.') }}</p></div><span id="datastoreUpdated" class="muted">—</span></div><div id="storageWarning" class="banner hidden"></div><div class="summary-grid">{% for id, de, en in [('Total', 'Gesamtspeicher', 'Total storage'), ('Used', 'Belegt', 'Used'), ('Free', 'Verfügbar', 'Available'), ('Percent', 'Auslastung', 'Usage')] %}<article class="summary-card"><span class="summary-icon">{{ icon('disk') }}</span><div><strong id="datastore{{ id }}">—</strong><span>{{ tr(de, en) }}</span></div></article>{% endfor %}</div><div class="dashboard-grid"><article class="panel"><div class="panel-heading"><h3>{{ tr('Medienkategorien', 'Media categories') }}</h3><code id="datastorePath">—</code></div><div class="coverage-row"><div id="datastoreCapacityLabel" class="muted">—</div><div class="track"><span id="datastoreCapacityBar"></span></div></div><div id="datastoreCategories"></div></article><div class="panel-stack"><article class="panel"><div class="panel-heading"><h3>I/O Pressure</h3><strong id="ioPressureLabel">—</strong></div><p class="panel-note">{{ tr('Festplatten-Druck aus Linux PSI · letzte 10 Sekunden', 'Disk pressure from Linux PSI · last 10 seconds') }}</p>{% for name in ['Some', 'Full'] %}<div class="pressure-row"><span>{{ name }}</span><strong id="ioPressure{{ name }}">—</strong><div class="track"><span id="ioPressure{{ name }}Bar"></span></div></div>{% endfor %}</article><article class="panel raid-panel"><div class="panel-heading"><div><h3>RAID</h3><code id="raidDevice">—</code></div><span id="raidStatusBadge" class="badge">—</span></div><div id="raidWarning" class="banner hidden"></div><div id="raidSummary" class="system-details"></div><div id="raidRecovery" class="recovery-box hidden"><div class="metric-line"><strong>Rebuild / Resync</strong><strong id="raidRecoveryPercent">—</strong></div><div class="track"><span id="raidRecoveryBar"></span></div><div class="metric-line muted"><span id="raidRecoverySpeed">—</span><span id="raidRecoveryFinish">—</span></div></div><div id="raidDisks" class="raid-disks"></div></article></div></div></section>
|
||||
<section id="graphics" class="view"><div class="page-heading"><div><h1>NVIDIA Graphics</h1><p>{{ tr('GPU-Auslastung, Temperatur, VRAM und Transcoding-Prozesse.', 'GPU usage, temperature, VRAM and transcoding processes.') }}</p></div><span id="graphicsStatus" class="source-label">—</span></div><div id="graphicsSummary" class="summary-grid"></div><div id="graphicsCards" class="graphics-cards"></div><div class="section-title">{{ icon('chip') }}<h2>{{ tr('GPU-Prozesse', 'GPU processes') }}</h2></div><div id="graphicsProcesses" class="panel"></div></section>
|
||||
</main>
|
||||
</div>
|
||||
<dialog id="settingsModal" class="settings-modal" aria-labelledby="settingsTitle">
|
||||
<form method="dialog">
|
||||
<div class="panel-heading">
|
||||
<div><h2 id="settingsTitle">{{ tr('Einstellungen', 'Settings') }}</h2><p class="muted">{{ tr('Anzeige- und Sprachpräferenzen', 'Display and language preferences') }}</p></div>
|
||||
<button class="icon-button" aria-label="Schließen">{{ icon('close') }}</button>
|
||||
</div>
|
||||
{% for id, de, en in [('preferredAudio', 'Bevorzugte Audio-Sprache', 'Preferred audio language'), ('preferredSubs', 'Bevorzugte Untertitel-Sprache', 'Preferred subtitle language')] %}
|
||||
<label for="{{ id }}">{{ tr(de, en) }}</label>
|
||||
<select id="{{ id }}"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option>{% for name in language_names %}<option>{{ name }}</option>{% endfor %}</select>
|
||||
{% endfor %}
|
||||
<label class="setting-toggle"><input type="checkbox" id="showSeasonZero">{{ tr('Staffel 0 anzeigen', 'Show season 0') }}</label>
|
||||
<p class="panel-note">{{ tr('Gilt für Serien und die Liste Ohne Deutsch. Automatisches Scanning läuft serverseitig.', 'Applies to series and the Without German list. Automatic scanning runs on the server.') }}</p>
|
||||
<div class="settings-actions"><button value="cancel">{{ tr('Abbrechen', 'Cancel') }}</button><button id="settingsSave" type="button" class="primary-button">{{ tr('Speichern', 'Save') }}</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<script id="dashboardConfig" type="application/json">{{ {'sabEnabled': sab_enabled, 'libraryUpdatedAt': library_updated_at, 'libraryReady': library_ready}|tojson }}</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,136 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
# Import without contacting the user's services or starting hardware probes.
|
||||
with patch('threading.Thread.start'):
|
||||
import app as dashboard
|
||||
|
||||
|
||||
class LanguageTests(unittest.TestCase):
|
||||
def probe(self, streams):
|
||||
result = SimpleNamespace(returncode=0, stdout=json.dumps({'streams': streams, 'format': {'size': '1024'}}))
|
||||
with patch.object(dashboard.subprocess, 'run', return_value=result), patch.object(dashboard, 'external_subtitle_files', return_value=[]):
|
||||
return dashboard.run_ffprobe('/media/example.mkv')
|
||||
|
||||
def test_codes_names_and_regions(self):
|
||||
for value, expected in [('GER', 'de'), ('de-DE', 'de'), ('eng_US', 'en'), ('German', 'de'), ('Portuguese', 'pt'), ('dut', 'nl'), ('pol', 'pl'), ('zho-Hans', 'zh'), ('unknown', 'und'), (None, 'und')]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(dashboard.norm_lang(value)['code'], expected)
|
||||
|
||||
def test_audio_title_fallback(self):
|
||||
info = self.probe([{'codec_type': 'audio', 'tags': {'language': 'und', 'title': 'Deutsch / DTS-HD MA 5.1'}}])
|
||||
self.assertEqual(info['audio_languages'][0]['name'], 'Deutsch')
|
||||
self.assertEqual(info['audio_details'][0]['language_source'], 'title')
|
||||
|
||||
def test_uppercase_tags_and_subtitle_titles(self):
|
||||
info = self.probe([{'codec_type': 'audio', 'tags': {'LANGUAGE': 'eng'}}, {'codec_type': 'subtitle', 'tags': {'TITLE': 'Português (forced)'}}])
|
||||
self.assertEqual(info['audio_languages'][0]['code'], 'en')
|
||||
self.assertEqual(info['subtitle_languages'][0]['code'], 'pt')
|
||||
|
||||
def test_explicit_tag_wins_over_title(self):
|
||||
info = self.probe([{'codec_type': 'audio', 'tags': {'language': 'jpn', 'title': 'German commentary'}}])
|
||||
self.assertEqual(info['audio_languages'][0]['code'], 'ja')
|
||||
|
||||
def test_ambiguous_titles_stay_unknown(self):
|
||||
for title in ['DTS 5.1', 'German / English', 'Original', 'Commentary']:
|
||||
with self.subTest(title=title):
|
||||
info = self.probe([{'codec_type': 'audio', 'tags': {'title': title}}])
|
||||
self.assertEqual(info['audio_languages'][0]['code'], 'und')
|
||||
|
||||
def test_languages_are_deduplicated(self):
|
||||
info = self.probe([{'codec_type': 'audio', 'tags': {'language': code}} for code in ['de', 'deu', 'ger', 'eng']])
|
||||
self.assertEqual([lang['code'] for lang in info['audio_languages']], ['de', 'en'])
|
||||
|
||||
def test_external_subtitles_only_use_suffix(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
media = Path(tmp) / 'English [2024].mkv'
|
||||
media.touch()
|
||||
for suffix in ['.pt-BR.forced.srt', '.ger.ass', '.forced.srt']:
|
||||
media.with_name(media.stem + suffix).touch()
|
||||
self.assertEqual({lang['code'] for lang in dashboard.external_subtitle_languages(media)}, {'pt', 'de'})
|
||||
|
||||
def test_file_metadata_fallback(self):
|
||||
file = {'path': '/remote/movie.mkv', 'mediaInfo': {'audioLanguages': 'German / English'}}
|
||||
with patch.object(dashboard, 'cached', return_value={'audio_languages': [dashboard.norm_lang('und')]}), patch.object(dashboard, 'external_subtitle_languages', return_value=[]):
|
||||
row = dashboard.media_row('Movie', 2024, file, '/remote', '/local')
|
||||
self.assertEqual({lang['code'] for lang in row['audio_languages']}, {'de', 'en'})
|
||||
self.assertTrue(all(lang['source'] == 'arr' for lang in row['audio_languages']))
|
||||
self.assertEqual(row['state']['label'], 'DE + EN')
|
||||
|
||||
def test_partial_known_tracks_not_replaced(self):
|
||||
file = {'path': '/movie.mkv', 'languages': [{'name': 'German'}]}
|
||||
tracks = [dashboard.norm_lang('en'), dashboard.norm_lang('und')]
|
||||
with patch.object(dashboard, 'cached', return_value={'audio_languages': tracks}), patch.object(dashboard, 'external_subtitle_languages', return_value=[]):
|
||||
row = dashboard.media_row('Movie', 2024, file, '', '')
|
||||
self.assertEqual(row['audio_languages'], tracks)
|
||||
self.assertEqual(row['state']['label'], 'Sprache prüfen')
|
||||
|
||||
def test_missing_german_ignores_failed_and_pending_scans(self):
|
||||
base = {'missing': False, 'audio_languages': [dashboard.norm_lang('en')]}
|
||||
self.assertTrue(dashboard.needs_german_review(base))
|
||||
for extra in [{'pending': True}, {'error': 'ffprobe failed'}, {'missing': True}, {'subtitle_languages': [dashboard.norm_lang('de')]}]:
|
||||
self.assertFalse(dashboard.needs_german_review({**base, **extra}))
|
||||
|
||||
def test_english_only_is_not_multilingual(self):
|
||||
self.assertEqual(dashboard.language_state([dashboard.norm_lang('en'), dashboard.norm_lang('fr')])['label'], 'Andere')
|
||||
|
||||
def test_release_separators_and_dl_not_guessed(self):
|
||||
self.assertEqual({lang['code'] for lang in dashboard.release_languages('Movie_[GER]_ENG_1080p')}, {'de', 'en'})
|
||||
self.assertEqual([lang['code'] for lang in dashboard.release_languages('Movie.German.DL.1080p')], ['de'])
|
||||
|
||||
def test_original_movie_language_is_not_file_metadata(self):
|
||||
self.assertEqual(dashboard.file_metadata_languages({'originalLanguage': {'name': 'German'}}), [])
|
||||
|
||||
def test_path_mapping_respects_directory_boundary(self):
|
||||
self.assertEqual(dashboard.map_path('/data/films/a.mkv', '/data/films', '/local'), '/local/a.mkv')
|
||||
self.assertEqual(dashboard.map_path('/data/films-other/a.mkv', '/data/films', '/local'), '/data/films-other/a.mkv')
|
||||
|
||||
|
||||
class CacheTests(unittest.TestCase):
|
||||
def test_old_scanner_and_transient_errors_are_retried(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.object(dashboard, 'DB_PATH', str(Path(tmp) / 'cache.db')):
|
||||
media = Path(tmp) / 'film.mkv'
|
||||
media.touch()
|
||||
con = dashboard.db()
|
||||
def store(version, error=None, age=0):
|
||||
data = {'scanner_version': version, 'external_subtitle_files': [], 'audio_languages': []}
|
||||
if error:
|
||||
data['error'] = error
|
||||
con.execute('INSERT OR REPLACE INTO media_cache VALUES(?,?,?,?)', (str(media), media.stat().st_mtime, json.dumps(data), (datetime.now(timezone.utc) - timedelta(seconds=age)).isoformat()))
|
||||
con.commit()
|
||||
store(dashboard.SCANNER_VERSION - 1)
|
||||
self.assertIsNone(dashboard.cached(media))
|
||||
store(dashboard.SCANNER_VERSION)
|
||||
self.assertIsNotNone(dashboard.cached(media))
|
||||
store(dashboard.SCANNER_VERSION, error='timeout')
|
||||
self.assertIsNotNone(dashboard.cached(media))
|
||||
store(dashboard.SCANNER_VERSION, error='timeout', age=301)
|
||||
self.assertIsNone(dashboard.cached(media))
|
||||
con.close()
|
||||
|
||||
def test_missing_file_does_not_loop_scans(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = dashboard.cached(Path(tmp) / 'missing.mkv')
|
||||
self.assertFalse(result['pending'])
|
||||
self.assertIn('Datei nicht gefunden', result['error'])
|
||||
|
||||
|
||||
class PageTests(unittest.TestCase):
|
||||
def test_empty_and_enabled_views_render(self):
|
||||
for enabled in [False, True]:
|
||||
with self.subTest(enabled=enabled), patch.object(dashboard, 'SONARR_URL', 'http://sonarr' if enabled else ''), patch.object(dashboard, 'SONARR_API_KEY', 'test' if enabled else ''), patch.object(dashboard, 'SAB_URL', 'http://sab' if enabled else ''), patch.object(dashboard, 'SAB_API_KEY', 'test' if enabled else ''):
|
||||
response = dashboard.app.test_client().get('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
page = response.get_data(as_text=True)
|
||||
self.assertIn('id="sidebar"', page)
|
||||
self.assertEqual('id="sonarr"' in page, enabled)
|
||||
self.assertEqual('id="downloads"' in page, enabled)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user