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.slice(0, 1))} ${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)} ${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')} 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) -%}
+
+{%- endmacro %}
+{% macro filters(prefix, series=false) -%}
+
+ {{ icon('search') }}
+ Alle Audio-Sprachen {% for name in language_names %}{{ name }} {% endfor %}
+ Alle Untertitel {% for name in language_names %}{{ name }} {% endfor %}
+ Alle Status {% for name in ['DE + EN', 'Deutsch', 'English only', 'Andere', 'Sprache prüfen', 'Scan ausstehend', 'Fehler', 'Downloading'] %}{{ name }} {% endfor %}
+
+{%- endmacro %}
+{% macro nav_button(view, symbol, de, en, count_id='') -%}
+{{ icon(symbol) }}{{ tr(de, en) }}{% if count_id %}0 {% endif %}
+{%- endmacro %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Media Max
-
-
-
- Dashboard RADARR · Filme {% if sonarr_enabled %} SONARR · Serien {% endif %} Ohne Deutsch {{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} {% if sab_enabled %} SABNZBD · Downloads 0 {% endif %} Datastore Graphics
-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 … Laufwerke werden geladen …
Dienste Verbindung und Bibliotheksstatus
R Radarr Filme & Qualität
Aktiv S Sonarr Serien & Episoden
{% if sonarr_enabled %}Aktiv{% else %}Nicht konfiguriert{% endif %} ↓ SABnzbd Downloads & Queue
{% if sab_enabled %}Wird geprüft …{% else %}Nicht konfiguriert{% endif %}
-
-Alle Audio-Sprachen Deutsch Englisch Japanisch Koreanisch Alle Untertitel Deutsch Englisch Japanisch Koreanisch Alle Status DE + EN Deutsch English only
-Scan wird vorbereitet … Radarr: {{ radarr_media_path }}
-Film Quality Status Audio Untertitel Video Größe
-{% for r in rows %}{{ 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 }} {% endfor %}
-
-{% if sonarr_enabled %}Alle Audio-Sprachen Deutsch Englisch Japanisch Koreanisch Alle Untertitel Deutsch Englisch Japanisch Koreanisch Alle Status DE + EN Deutsch English only
{{ series|length }} Serien Scan 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 Episode Quality Status Audio Untertitel Video Größe {% for r in group.episodes %}{{ 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 %}
{% endfor %}{% endif %}{% endfor %}
-
{% endif %}
-{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} Einträge ohne Deutsch Scan wird vorbereitet … Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet
-
Film Qualität Status Audio Untertitel Video Größe {% for r in missing_german_movies %}{{ 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 }} {% endfor %}
-{% for group in missing_german_series %}
{{ group.title }} ({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden ohne Deutsch Episode Qualität Status Audio Untertitel Video Größe {% for r in group.episodes %}{{ 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 %}
{% 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
Download Status Fortschritt Größe / Rest Restzeit Quelle Sprache Downloads werden geladen …
{% endif %}
-MEDIA MAX · GRAPHICS
NVIDIA Graphics GPU-Auslastung, Speicher, Temperatur, Leistung und laufende Prozesse.
Wird geladen …
GPU-Daten werden geladen …
GPU-Prozesse Aktuelle Compute-Prozesse laut NVIDIA SMI
MEDIA MAX · DATASTORE
Datastore Speicherbelegung, Medienkategorien und RAID-Status von /nesflix.
Wird geladen …
Speicher nach Kategorie Anhand der tatsächlichen Verzeichnisse
— — —
RAID Rebuild —
Geschwindigkeit — Verbleibend —
-
-Einstellungen Deine Anzeige- und Sprachpräferenzen
× Bevorzugte Audio-Sprache Keine Präferenz Deutsch Englisch Japanisch Koreanisch Bevorzugte Untertitel-Sprache Keine Präferenz Deutsch Englisch Japanisch Koreanisch Abbrechen Speichern
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Media Max
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Zum Inhalt
+
+
+
+
+
+ {% if error %}{{ icon('alert') }}{{ 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') }} {{ rows|length }} {{ tr('Filme', 'Movies') }} Radarr · {{ dashboard_stats.movie_files }} {{ tr('vorhanden', 'available') }}
+
{{ icon('series') }} {{ series|length }} {{ tr('Serien', 'Series') }} {{ dashboard_stats.episode_files }} {{ tr('Episoden vorhanden', 'episodes available') }}
+
{{ icon('language') }} 0 {{ tr('Ohne Deutsch', 'Without German') }} {{ tr('Audio & Untertitel prüfen', 'Review audio & subtitles') }}
+
{{ icon('download') }} {{ '—' if sab_enabled else '0' }} {{ tr('Downloads', 'Downloads') }} {{ 'SABnzbd …' if sab_enabled else 'SABnzbd nicht konfiguriert' }}
+
+ {{ 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') }}
+
{{ 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') }}
* {{ 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.') }}
{{ tr('Keine Filme für diese Filter.', 'No movies match these filters.') }}
+ {% 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 %}
* {{ 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.') }}
{{ tr('Keine Serien für diese Filter.', 'No series match these filters.') }}
{% 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 %}
{{ icon('check') }}{{ tr('Keine Einträge ohne Deutsch in der aktuellen Ansicht.', 'No entries without German in the current view.') }}
+ {% 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 {% 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'] %}{% endfor %}
+ NVIDIA Graphics {{ tr('GPU-Auslastung, Temperatur, VRAM und Transcoding-Prozesse.', 'GPU usage, temperature, VRAM and transcoding processes.') }}
—
{{ icon('chip') }}
{{ tr('GPU-Prozesse', 'GPU processes') }}
+
+
+
+
+
+
+
+
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()