From 15310c5e611ed36cebe58940c63f619d4be25ebc Mon Sep 17 00:00:00 2001 From: nessi Date: Wed, 30 Sep 2026 16:07:54 +0200 Subject: [PATCH] feat: redesign dashboard with Tracearr-inspired layout and improve language detection --- README.md | 15 ++ app.py | 176 +++++++++++++------ static/dashboard.css | 115 +++++++++++++ static/dashboard.js | 357 ++++++++++++++++++++++++++++++++++++++ static/manifest.json | 4 +- static/sw.js | 4 +- templates/index.html | 402 ++++++++++++++++--------------------------- tests/test_app.py | 136 +++++++++++++++ 8 files changed, 895 insertions(+), 314 deletions(-) create mode 100644 static/dashboard.css create mode 100644 static/dashboard.js create mode 100644 tests/test_app.py diff --git a/README.md b/README.md index 3f3f1b6..40b6a8b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app.py b/app.py index f58909a..26efe09 100644 --- a/app.py +++ b/app.py @@ -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, + 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) diff --git a/static/dashboard.css b/static/dashboard.css new file mode 100644 index 0000000..a7a7f5e --- /dev/null +++ b/static/dashboard.css @@ -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; } +} diff --git a/static/dashboard.js b/static/dashboard.js new file mode 100644 index 0000000..7385573 --- /dev/null +++ b/static/dashboard.js @@ -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 => ``; +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 => `
${html(message)}
`; +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 = `${t('Staffel', 'Season')} ${html(season)}`; + 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 = `
Load Average${(data.load || []).map(value => Number(value).toFixed(2)).join(' · ') || '—'}
Uptime${html(duration(data.uptime_seconds))}
${t('Prozesse', 'Processes')}${number(data.processes)}
`; + $('#diskList').innerHTML = (data.disks || []).map(disk => `
${html(disk.label)}${html(disk.path)}
${html(disk.used)} / ${html(disk.total)}${percent(disk.percent).toFixed(1)}%
`).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 => `
${html(service.name)}${html(service.detail || '—')}
${html(labels[service.status] || service.label)}
`).join(''); +} + +function downloadRow(item) { + const pct = percent(item.percentage); + const languages = (item.languages || []).map(lang => `${html(lang.flag)} ${html(lang.name)}`).join('') || `${t('Nicht erkannt', 'Not detected')}`; + return `${html(item.title)}${html(item.status)}
${pct.toFixed(0)}%
${html(item.size)}${html(item.remaining)}${html(item.timeleft)}${html(item.source)}
${languages}
`; +} + +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('') : `${t('Keine Einträge.', 'No entries.')}`; + } +} + +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 => `
${html(category.label)}${html(category.size || '0 B')}
${Number(category.percent || 0).toFixed(1)}% ${t('vom belegten Speicher', 'of used storage')} · ${number(category.files)} ${t('Dateien', 'files')}
`).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 = `
Level${html(raid.level)}
${t('Aktive Laufwerke', 'Active drives')}${html(raid.active_devices)} / ${html(raid.raid_devices)}
${t('Fehlend', 'Missing')}${html(raid.failed_devices ?? '—')}
`; + 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 => `
${html(disk.device)}Slot ${html(disk.slot)} · ${html(disk.model)}${html(disk.size)} · Serial: ${html(disk.serial)}SMART: ${html(disk.smart || t('Nicht aktiviert', 'Not enabled'))} · ${html(disk.temperature || '—')}
${html(disk.status)}
`).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]) => `
${icon('chip')}
${value}${html(label)}
`).join(''); + $('#graphicsCards').innerHTML = gpus.length ? gpus.map(gpu => { + const used = Number(gpu['memory.used']) || 0, total = Number(gpu['memory.total']) || 0; + return `
GPU ${html(gpu.index)}

${html(gpu.name)}

${gpuValue(gpu['temperature.gpu'], ' °C')}
${t('GPU-Last', 'GPU load')}${gpuValue(gpu['utilization.gpu'], '%')}
${t('Memory-Last', 'Memory load')}${gpuValue(gpu['utilization.memory'], '%')}
${t('Leistung', 'Power')}${gpuValue(gpu['power.draw'], ' W')} / ${gpuValue(gpu['power.limit'], ' W')}
P-State${html(gpu.pstate)}
VRAM${total ? `${(used / 1024).toFixed(1)} / ${(total / 1024).toFixed(1)} GB` : '—'}
${t('Treiber', 'Driver')}: ${html(gpu.driver_version)} · PCI: ${html(gpu['pci.bus_id'])}UUID: ${html(gpu.uuid)}Serial: ${html(gpu.serial)}Clocks: ${gpuValue(gpu['clocks.current.graphics'], ' MHz')} · Memory: ${gpuValue(gpu['clocks.current.memory'], ' MHz')}Fan: ${gpuValue(gpu['fan.speed'], '%')} · Compute: ${html(gpu.compute_mode)}
`; + }).join('') : `
${empty(data.error || t('Keine NVIDIA-GPU erkannt.', 'No NVIDIA GPU detected.'))}
`; + $('#graphicsProcesses').innerHTML = (data.processes || []).map(process => `
${html(process.name)}PID ${html(process.pid)}${html(process.memory)}
`).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(() => {})); diff --git a/static/manifest.json b/static/manifest.json index 3b188cc..4df75dd 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -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"}, diff --git a/static/sw.js b/static/sw.js index e9d2e00..19c779b 100644 --- a/static/sw.js +++ b/static/sw.js @@ -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))); diff --git a/templates/index.html b/templates/index.html index 26c675e..6bee91a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,254 +1,150 @@ +{% macro icon(name, class='') -%} + +{%- endmacro %} +{% macro tr(de, en) -%}{{ de }}{%- endmacro %} +{% macro languages(items) -%} +
{% for l in items %}{{ l.flag }} {{ l.name }}{% if l.source == 'arr' %}*{% endif %}{% else %}—{% endfor %}
+{%- endmacro %} +{% macro media_table(items, id='', episode=false, review=false) -%} +
+ + {% for r in items %} + + + + + + {% else %}{% endfor %} +
{{ tr('Episode' if episode else 'Film', 'Episode' if episode else 'Movie') }}{{ tr('Qualität', 'Quality') }}{{ tr('Status', 'Status') }}Audio{{ tr('Untertitel', 'Subtitles') }}{{ tr('Video', 'Video') }}{{ tr('Größe', 'Size') }}
{{ r.title }}{% if r.year %}{{ r.year }}{% endif %}{% if r.error %}{{ icon('alert') }}{% endif %}{{ r.quality }}{{ r.state.label }}{{ languages(r.audio_languages) }}{{ languages(r.subtitle_languages) }}{{ r.video_codec }}{{ r.size }}
{{ tr('Keine Einträge vorhanden.', 'No entries available.') }}
+{%- endmacro %} +{% macro filters(prefix, series=false) -%} +
+ + + + +
+{%- endmacro %} +{% macro nav_button(view, symbol, de, en, count_id='') -%} + +{%- endmacro %} - - - - - - - - - - - - - - - - - - - - - - - - - - -Media Max - -
MEDIA
Media Max
Audio- und Untertitelspuren aus den Mediendateien
-
- -
MEDIA MAX

Dashboard

Deine gesamte Medienbibliothek und Systemauslastung auf einen Blick.

Live
▣
Filme{{ rows|length }}Radarr Library
▤
Serien{{ series|length }}Sonarr Shows
⚠
Ohne Deutsch{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}Audio oder Untertitel prüfen
↓
Downloads—SABnzbd wird geladen …

Systemauslastung

Aktuelle Werte aus dem Dashboard-Container

Wird geladen …
CPU—
RAM—
Laufwerke werden geladen …

Dienste

Verbindung und Bibliotheksstatus

RadarrFilme & Qualität
Aktiv
SonarrSerien & Episoden
{% if sonarr_enabled %}Aktiv{% else %}Nicht konfiguriert{% endif %}
SABnzbdDownloads & Queue
{% if sab_enabled %}Wird geprüft …{% else %}Nicht konfiguriert{% endif %}
-
-
-
Scan wird vorbereitet …Radarr: {{ radarr_media_path }}
-
-{% for r in rows %}{% endfor %} -
FilmQualityStatusAudioUntertitelVideoGröße
{{ r.title }}{{ r.year or '' }}{% if r.error %} ⚠{% endif %}{{ r.quality }}{{ r.state.label }}
{% for l in r.audio_languages %}{{ l.flag }} {{ l.name }}{% else %}—{% endfor %}
{% for l in r.subtitle_languages %}{{ l.flag }} {{ l.name }}{% else %}—{% endfor %}
{{ r.video_codec }}{{ r.size }}
-{% if sonarr_enabled %}
{{ series|length }} SerienScan wird vorbereitet …Dashboard: {{ local_series_path }}
-{% for category in series_categories %}{% if category.series %}

{{ category.label }}

{{ category.series|length }} Serien
{% for group in category.series %}
{{ group.title }} ({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden{% for r in group.episodes %}{% endfor %}
EpisodeQualityStatusAudioUntertitelVideoGröße
{{ r.title }}{{ r.quality }}{{ r.state.label }}
{% for l in r.audio_languages %}{{ l.flag }} {{ l.name }}{% else %}—{% endfor %}
{% for l in r.subtitle_languages %}{{ l.flag }} {{ l.name }}{% else %}—{% endfor %}
{{ r.video_codec }}{{ r.size }}
{% endfor %}
{% endif %}{% endfor %} -
{% endif %} -
{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} Einträge ohne DeutschScan wird vorbereitet …Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet
-{% for r in missing_german_movies %}{% endfor %}
FilmQualitätStatusAudioUntertitelVideoGröße
{{ r.title }}{{ r.year or '' }}{{ r.quality }}Ohne Deutsch
{% for l in r.audio_languages %}{{ l.flag }} {{ l.name }}{% else %}—{% endfor %}
{% for l in r.subtitle_languages %}{{ l.flag }} {{ l.name }}{% else %}—{% endfor %}
{{ r.video_codec }}{{ r.size }}
-{% for group in missing_german_series %}
{{ group.title }} ({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden ohne Deutsch{% for r in group.episodes %}{% endfor %}
EpisodeQualitätStatusAudioUntertitelVideoGröße
{{ r.title }}{{ r.quality }}Ohne Deutsch
{% for l in r.audio_languages %}{{ l.flag }} {{ l.name }}{% else %}—{% endfor %}
{% for l in r.subtitle_languages %}{{ l.flag }} {{ l.name }}{% else %}—{% endfor %}
{{ r.video_codec }}{{ r.size }}
{% endfor %} -{% if not missing_german_movies and not missing_german_series %}
Alle vorhandenen Filme und Episoden haben deutsches Audio oder deutsche Untertitel.
{% endif %}
-{% if sab_enabled %}
SABNZBD · DOWNLOAD CENTER

Download-Übersicht

Queue, Fortschritt und verbleibender Speicherplatz auf einen Blick.

Verbinde mit SABnzbd …
↓
Noch zu laden—Wird berechnet …
◉
Aktive Downloads—Verlauf: —
↯
Geschwindigkeit—Aktuelle Queue
✓
Status——
DownloadStatusFortschrittGröße / RestRestzeitQuelleSprache
Downloads werden geladen …
{% endif %} -
MEDIA MAX · GRAPHICS

NVIDIA Graphics

GPU-Auslastung, Speicher, Temperatur, Leistung und laufende Prozesse.

Wird geladen …
▣
GPUs—
◒
GPU-Auslastung—
▤
VRAM—
°
Temperatur—
GPU-Daten werden geladen …

GPU-Prozesse

Aktuelle Compute-Prozesse laut NVIDIA SMI

Keine Prozesse erkannt
MEDIA MAX · DATASTORE

Datastore

Speicherbelegung, Medienkategorien und RAID-Status von /nesflix.

Wird geladen …
▣
Gesamtspeicher—
◒
Belegt—
◌
Frei—
%
Auslastung—

Gesamtauslastung

/nesflix

—

Speicher nach Kategorie

Anhand der tatsächlichen Verzeichnisse

Wird geladen …

RAID-Status

/dev/md127

Wird geladen …
———
Wird geladen …
-
- - - - - - - - - - - - - - - - - - - - + + + + + + Media Max + + + + + + + +
+
+ + +
+ +
+ + +
+
+
+
+ {% if error %}{% endif %} + {% if not library_ready %}
{{ tr('Bibliothek wird synchronisiert. Diese Ansicht aktualisiert sich automatisch.', 'Library is syncing. This view will update automatically.') }}
{% endif %} + +
+

{{ tr('Dashboard', 'Dashboard') }}

{{ tr('Deine Medien. Deine Sprachen. Dein System.', 'Your media. Your languages. Your system.') }}

{{ tr('Live-Monitoring', 'Live monitoring') }}
+
+ + + + +
+
{{ icon('movie') }}

{{ tr('Bibliotheksübersicht', 'Library overview') }}

+
+

{{ tr('Medienbestand', 'Media collection') }}

Radarr / Sonarr
+ {% 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)] %} +
{{ tr(de, en) }}{{ files }} / {{ total }}
{{ missing }} {{ tr('ohne Datei', 'without a file') }}
{% endfor %} + +
+

{{ tr('Spracherkennung', 'Language detection') }}

{{ icon('language') }}
{{ dashboard_stats.german_tracks }}{{ tr('Mit Deutsch', 'With German') }}
{{ dashboard_stats.unknown_audio }}{{ tr('Audio unbekannt', 'Unknown audio') }}
{{ dashboard_stats.pending }}{{ tr('Scan ausstehend', 'Scan pending') }}
{{ dashboard_stats.scan_errors }}{{ tr('Scanfehler', 'Scan errors') }}
+

{{ 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.') }}

+ {% if language_review %}
{% for r in language_review %}
{{ r.title }}{{ 'Scanfehler' if r.error else 'Unbekannt' }}
{% endfor %}
{% endif %} +
+
+
{{ icon('activity') }}

{{ tr('System & Dienste', 'System & services') }}

—
+
+

{{ tr('Systemauslastung', 'System resources') }}

{{ icon('chip') }}
CPU—
RAM—
—
+

{{ tr('Verbundene Dienste', 'Connected services') }}

—
{{ tr('Dienste werden geprüft …', 'Checking services …') }}
+
+
+

{{ tr('Filme', 'Movies') }}

{{ tr('Qualität, Audio und Untertitel deiner Radarr-Bibliothek.', 'Quality, audio and subtitles in your Radarr library.') }}

Radarr
{{ filters('movie') }}
—
{{ media_table(rows, 'movies') }}
+ {% if sonarr_enabled %}

{{ tr('Serien', 'Series') }}

{{ tr('Deine Serien, nach Kategorien und Staffeln geordnet.', 'Your shows, grouped by category and season.') }}

Sonarr
{{ filters('series', true) }}
—
{% for category in series_categories %}{% if category.series %}

{{ category.label }}

{{ category.series|length }}
{% for group in category.series %}
{{ icon('series') }}{{ group.title }}{{ group.year or '' }} · {{ group.episodes|length }} {{ tr('Episoden', 'episodes') }}{{ media_table(group.episodes, episode=true) }}
{% endfor %}
{% endif %}{% endfor %}
{% endif %} +

{{ tr('Ohne Deutsch', 'Without German') }}

{{ tr('Keine deutsche Audio- oder Untertitelspur erkannt. Unbekannte Spuren bitte prüfen.', 'No German audio or subtitles detected. Please review unknown tracks.') }}

{{ icon('language') }}
——
{% if missing_german_movies %}

{{ tr('Filme', 'Movies') }}

{{ media_table(missing_german_movies, 'missingMovies', review=true) }}
{% endif %}{% if missing_german_series %}

{{ tr('Serien', 'Series') }}

{% for group in missing_german_series %}
{{ icon('series') }}{{ group.title }}{{ group.episodes|length }} {{ tr('Episoden', 'episodes') }}{{ media_table(group.episodes, episode=true, review=true) }}
{% endfor %}{% endif %}
+ {% if sab_enabled %}

{{ tr('Downloads', 'Downloads') }}

{{ tr('Aktuelle Queue und zuletzt abgeschlossene Downloads.', 'Current queue and recently completed downloads.') }}

SABnzbd
{{ icon('download') }}
—{{ tr('Noch zu laden', 'Remaining') }}
{{ icon('movie') }}
—{{ tr('Queue-Einträge', 'Queue entries') }}
{{ icon('activity') }}
—{{ tr('Geschwindigkeit', 'Speed') }}
{{ icon('check') }}
—{{ tr('Im Verlauf', 'In history') }}
{% for id, de, en in [('activeDownloads', 'Aktive Downloads', 'Active downloads'), ('otherDownloads', 'Queue & Verlauf', 'Queue & history')] %}
{{ icon('download') }}

{{ tr(de, en) }}

0
DownloadStatus{{ tr('Fortschritt', 'Progress') }}{{ tr('Größe / Rest', 'Size / remaining') }}{{ tr('Restzeit', 'Time left') }}{{ tr('Quelle', 'Source') }}{{ tr('Sprache', 'Language') }}
{% endfor %}
{% endif %} +

{{ tr('Speicher & RAID', 'Storage & RAID') }}

{{ tr('Speicherbelegung, Medienkategorien und Zustand deiner Laufwerke.', 'Storage usage, media categories and drive health.') }}

—
{% for id, de, en in [('Total', 'Gesamtspeicher', 'Total storage'), ('Used', 'Belegt', 'Used'), ('Free', 'Verfügbar', 'Available'), ('Percent', 'Auslastung', 'Usage')] %}
{{ icon('disk') }}
—{{ tr(de, en) }}
{% endfor %}

{{ tr('Medienkategorien', 'Media categories') }}

—
—

I/O Pressure

—

{{ tr('Festplatten-Druck aus Linux PSI · letzte 10 Sekunden', 'Disk pressure from Linux PSI · last 10 seconds') }}

{% for name in ['Some', 'Full'] %}
{{ name }}—
{% endfor %}

RAID

—
—
+

NVIDIA Graphics

{{ tr('GPU-Auslastung, Temperatur, VRAM und Transcoding-Prozesse.', 'GPU usage, temperature, VRAM and transcoding processes.') }}

—
{{ icon('chip') }}

{{ tr('GPU-Prozesse', 'GPU processes') }}

+
+
+ +
+
+

{{ tr('Einstellungen', 'Settings') }}

{{ tr('Anzeige- und Sprachpräferenzen', 'Display and language preferences') }}

+ +
+ {% for id, de, en in [('preferredAudio', 'Bevorzugte Audio-Sprache', 'Preferred audio language'), ('preferredSubs', 'Bevorzugte Untertitel-Sprache', 'Preferred subtitle language')] %} + + + {% endfor %} + +

{{ 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.') }}

+
+
+
+ + + diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..af668a1 --- /dev/null +++ b/tests/test_app.py @@ -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()