From acbf3215bbf0e0d0a868c3c19a93958d3741dfec Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 13 Aug 2026 10:49:39 +0200 Subject: [PATCH] feat: add Sonarr support with background scanning and progress tracking Add Sonarr integration alongside existing Radarr functionality. Implement background ffprobe scanning with ThreadPoolExecutor, progress tracking API, and separate navigation for movies/series. Series are grouped by show with collapsible episodes. Reduce Gunicorn workers to 1, add scan status endpoint, and update README with Sonarr configuration instructions. --- Dockerfile | 2 +- README.md | 11 +- app.py | 358 ++++++++++++++++--------------------------- docker-compose.yml | 7 + templates/index.html | 328 +++------------------------------------ 5 files changed, 169 insertions(+), 537 deletions(-) diff --git a/Dockerfile b/Dockerfile index ed4c950..9d2e47f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY app.py . COPY templates ./templates EXPOSE 8099 -CMD ["gunicorn", "--bind", "0.0.0.0:8099", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"] +CMD ["gunicorn", "--bind", "0.0.0.0:8099", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"] diff --git a/README.md b/README.md index d8f9708..82bcb9e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,16 @@ docker compose up -d --build docker compose logs -f ``` -Beim ersten Laden erscheinen im Log `Gescannt:`-Einträge. +Die Seite lädt zuerst die Library-Metadaten. Die ffprobe-Scans laufen danach im Hintergrund; oben wird der Fortschritt angezeigt. Für Sonarr die optionalen Variablen und den Serien-Volume-Mount in `docker-compose.yml` aktivieren. Die Navigation trennt Filme und Serien, Serien sind pro Show aufklappbar. + +Sonarr benötigt: + +```yaml +SONARR_URL: "http://sonarr:8989" +SONARR_API_KEY: "..." +SONARR_MEDIA_PATH: "/data/serien" +LOCAL_SERIES_PATH: "/media/serien" +``` ## Bei weiterem Scanfehler diff --git a/app.py b/app.py index a5f5847..02b1c34 100644 --- a/app.py +++ b/app.py @@ -3,75 +3,46 @@ import logging import os import sqlite3 import subprocess -from pathlib import Path +import threading +from concurrent.futures import ThreadPoolExecutor from datetime import datetime +from pathlib import Path import requests -from flask import Flask, render_template, jsonify +from flask import Flask, jsonify, render_template, request RADARR_URL = os.environ.get("RADARR_URL", "http://radarr:7878").rstrip("/") RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") RADARR_MEDIA_PATH = os.environ.get("RADARR_MEDIA_PATH", "/data/filme").rstrip("/") LOCAL_MEDIA_PATH = os.environ.get("LOCAL_MEDIA_PATH", "/media/filme").rstrip("/") +SONARR_URL = os.environ.get("SONARR_URL", "").rstrip("/") +SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "") +SONARR_MEDIA_PATH = os.environ.get("SONARR_MEDIA_PATH", "/data/serien").rstrip("/") +LOCAL_SERIES_PATH = os.environ.get("LOCAL_SERIES_PATH", "/media/serien").rstrip("/") DB_PATH = os.environ.get("DB_PATH", "/data/cache.db") REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "20")) -logging.basicConfig( - level=os.environ.get("LOG_LEVEL", "INFO").upper(), - format="%(asctime)s %(levelname)s %(message)s", -) +logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("radarr-language-dashboard") - app = Flask(__name__) +executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "2"))) +jobs = {"radarr": {"state": "idle", "total": 0, "done": 0, "error": None}, "sonarr": {"state": "idle", "total": 0, "done": 0, "error": None}} +job_lock = threading.Lock() -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": "❓", -} +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":"❓"} def db(): Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) con = sqlite3.connect(DB_PATH) con.row_factory = sqlite3.Row - con.execute(""" - CREATE TABLE IF NOT EXISTS media_cache ( - path TEXT PRIMARY KEY, - mtime REAL NOT NULL, - data TEXT NOT NULL, - scanned_at TEXT NOT NULL - ) - """) + con.execute("CREATE TABLE IF NOT EXISTS media_cache (path TEXT PRIMARY KEY, mtime REAL NOT NULL, data TEXT NOT NULL, scanned_at TEXT NOT NULL)") return con -def radarr_get(endpoint): - if not RADARR_API_KEY: - raise RuntimeError("RADARR_API_KEY ist nicht gesetzt.") - r = requests.get( - f"{RADARR_URL}/api/v3/{endpoint.lstrip('/')}", - headers={"X-Api-Key": RADARR_API_KEY}, - timeout=REQUEST_TIMEOUT, - ) +def api_get(base, key, endpoint): + if not key: + raise RuntimeError(f"API-Key für {base} ist nicht gesetzt.") + r = requests.get(f"{base}/api/v3/{endpoint.lstrip('/')}", headers={"X-Api-Key": key}, timeout=REQUEST_TIMEOUT) r.raise_for_status() return r.json() @@ -81,215 +52,144 @@ def norm_lang(code): return {"code": code or "und", "name": name, "flag": FLAGS.get(name, "🌐")} def unique_langs(items): - seen, out = set(), [] + out, seen = [], set() for item in items: - key = item["name"] - if key not in seen: - seen.add(key) - out.append(item) + if item["name"] not in seen: + seen.add(item["name"]); out.append(item) return out -def map_radarr_path(path): - if not path: - return None - if RADARR_MEDIA_PATH and path.startswith(RADARR_MEDIA_PATH): - suffix = path[len(RADARR_MEDIA_PATH):].lstrip("/") - return str(Path(LOCAL_MEDIA_PATH) / suffix) +def map_path(path, remote, local): + if not path: return None + if remote and path.startswith(remote): + return str(Path(local) / path[len(remote):].lstrip("/")) return path def run_ffprobe(path): - proc = subprocess.run( - [ - "ffprobe", "-v", "error", - "-show_entries", - "format=duration,size,bit_rate:stream=index,codec_type,codec_name,profile,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") + 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 {}; lang = norm_lang(tags.get("language")); kind = stream.get("codec_type") + 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")}) + elif kind == "subtitle": subs.append(lang) + return {"audio_languages": unique_langs(audios), "subtitle_languages": unique_langs(subs), "audio_details": details, "video_codec": video or "—", "size": int((raw.get("format") or {}).get("size") or 0)} - raw = json.loads(proc.stdout or "{}") - audios, subs, audio_details = [], [], [] - video_codec = None - - for s in raw.get("streams", []): - stype = s.get("codec_type") - tags = s.get("tags") or {} - lang = norm_lang(tags.get("language")) - - if stype == "video" and not video_codec: - video_codec = s.get("codec_name") - elif stype == "audio": - audios.append(lang) - audio_details.append({ - "language": lang, - "codec": s.get("codec_name"), - "channels": s.get("channels"), - "layout": s.get("channel_layout"), - "title": tags.get("title"), - }) - elif stype == "subtitle": - subs.append(lang) - - fmt = raw.get("format") or {} - return { - "audio_languages": unique_langs(audios), - "subtitle_languages": unique_langs(subs), - "audio_details": audio_details, - "video_codec": video_codec or "—", - "size": int(fmt.get("size") or 0), - } - -def cached_probe(path): +def cached(path): p = Path(path) - if not p.exists(): - msg = f"Datei nicht gefunden: {p}" - log.warning(msg) - return {"error": msg} + if not p.exists(): return {"error": f"Datei nicht gefunden: {p}"} + mtime = p.stat().st_mtime; con = db(); row = con.execute("SELECT mtime,data FROM media_cache WHERE path=?", (str(p),)).fetchone(); con.close() + if row and float(row["mtime"]) == float(mtime): return json.loads(row["data"]) + return None - mtime = p.stat().st_mtime - con = db() - row = con.execute( - "SELECT mtime, data FROM media_cache WHERE path = ?", - (str(p),), - ).fetchone() - - if row and float(row["mtime"]) == float(mtime): - con.close() - return json.loads(row["data"]) - - try: - data = run_ffprobe(str(p)) - log.info("Gescannt: %s", p) - except Exception as e: - data = {"error": str(e)} - log.exception("Scanfehler für %s", p) - - 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() +def scan_one(path): + p = Path(path) + if not p.exists(): data = {"error": f"Datei nicht gefunden: {p}"} + else: + try: data = run_ffprobe(str(p)); log.info("Gescannt: %s", p) + except Exception as exc: data = {"error": str(exc)}; log.exception("Scanfehler für %s", p) + 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() return data def format_size(size): - if not size: - return "—" + if not size: return "—" value = float(size) for unit in ["B", "KB", "MB", "GB", "TB"]: - if value < 1024 or unit == "TB": - return f"{value:.1f} {unit}" + if value < 1024 or unit == "TB": return f"{value:.1f} {unit}" value /= 1024 -def language_state(audio_languages): - names = {x["name"] for x in audio_languages} - if "Deutsch" in names and "Englisch" in 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"} - return {"class": "neutral", "label": "Andere"} +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"} + return {"class":"neutral", "label":"Andere"} -def library(): - movies = radarr_get("movie") +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", []) + quality = ((((media_file or {}).get("quality") or {}).get("quality") or {}).get("name")) or "—" + row = {"title":name, "year":year, "quality":quality if media_file else "Keine Datei", "audio_languages":audios, "subtitle_languages":info.get("subtitle_languages", []), "video_codec":info.get("video_codec", "—"), "size":format_size(info.get("size") or (media_file or {}).get("size")), "radarr_path":path or "—", "local_path":local_path or "—", "state":language_state(audios) if audios else {"class":"neutral", "label":"Scan ausstehend" if info.get("pending") else ("Fehler" if info.get("error") else "Keine Spuren")}, "error":info.get("error"), "missing":not bool(media_file), "pending":bool(info.get("pending"))} + if extra: row.update(extra) + return row + +def radarr_rows(): rows = [] + for movie in api_get(RADARR_URL, RADARR_API_KEY, "movie"): + mf = movie.get("movieFile"); path = (mf or {}).get("path") or ((movie.get("path") and (mf or {}).get("relativePath")) and str(Path(movie["path"]) / mf["relativePath"])) + if mf and path: mf = dict(mf); mf["path"] = path + rows.append(media_row(movie.get("title", "—"), movie.get("year"), mf, RADARR_MEDIA_PATH, LOCAL_MEDIA_PATH)) + return sorted(rows, key=lambda x: x["title"].lower()) - for movie in movies: - mf = movie.get("movieFile") +def sonarr_groups(): + groups = [] + for series in api_get(SONARR_URL, SONARR_API_KEY, "series"): + episodes = api_get(SONARR_URL, SONARR_API_KEY, f"episode?seriesId={series['id']}&includeSeries=false") + files = {f["id"]: f for f in api_get(SONARR_URL, SONARR_API_KEY, f"episodefile?seriesId={series['id']}")} + items = [] + for episode in sorted(episodes, key=lambda x: (x.get("seasonNumber", 0), x.get("episodeNumber", 0))): + ef = files.get(episode.get("episodeFileId")); + if ef: ef = dict(ef); ef["path"] = str(Path(series.get("path") or "") / ef.get("relativePath", "")) + label = f"S{episode.get('seasonNumber', 0):02d}E{episode.get('episodeNumber', 0):02d} · {episode.get('title') or '—'}" + items.append(media_row(label, None, ef, SONARR_MEDIA_PATH, LOCAL_SERIES_PATH, {"episode":True})) + groups.append({"title":series.get("title", "—"), "year":series.get("year"), "episodes":items}) + return sorted(groups, key=lambda x: x["title"].lower()) - if not mf: - rows.append({ - "title": movie.get("title", "—"), - "year": movie.get("year"), - "quality": "Keine Datei", - "audio_languages": [], - "subtitle_languages": [], - "video_codec": "—", - "size": "—", - "radarr_path": "—", - "local_path": "—", - "state": {"class": "neutral", "label": "Keine Datei"}, - "error": None, - "missing": True, - }) - continue - - radarr_path = mf.get("path") - if not radarr_path and mf.get("relativePath"): - radarr_path = str(Path(movie.get("path") or "") / mf["relativePath"]) - - local_path = map_radarr_path(radarr_path) - info = cached_probe(local_path) if local_path else {"error": "Kein Dateipfad erhalten"} - - quality = (((mf.get("quality") or {}).get("quality") or {}).get("name")) or "—" - audios = info.get("audio_languages", []) - - rows.append({ - "title": movie.get("title", "—"), - "year": movie.get("year"), - "quality": quality, - "audio_languages": audios, - "subtitle_languages": info.get("subtitle_languages", []), - "video_codec": info.get("video_codec", "—"), - "size": format_size(info.get("size") or mf.get("size")), - "radarr_path": radarr_path or "—", - "local_path": local_path or "—", - "state": language_state(audios), - "error": info.get("error"), - "missing": False, - }) - - rows.sort(key=lambda x: x["title"].lower()) - return rows +def start_scan(source, force=False): + with job_lock: + if jobs[source]["state"] == "running" or (jobs[source]["state"] in ("done", "error") and not force): return + jobs[source] = {"state":"running", "total":0, "done":0, "error":None} + def work(): + try: + paths = [] + if source == "radarr": + for row in radarr_rows(): + if row["local_path"] != "—" and row["pending"]: paths.append(row["local_path"]) + else: + for group in sonarr_groups(): + paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "—" and r["pending"]) + with job_lock: jobs[source]["total"] = len(paths) + for path in paths: + scan_one(path) + with job_lock: jobs[source]["done"] += 1 + with job_lock: jobs[source]["state"] = "done" + except Exception as exc: + log.exception("%s-Scan fehlgeschlagen", source); jobs[source]["error"] = str(exc); jobs[source]["state"] = "error" + executor.submit(work) @app.route("/") def index(): - error = None - rows = [] + error = None; rows = []; series = [] try: - rows = library() - except Exception as e: - log.exception("Fehler beim Laden der Library") - error = str(e) - - return render_template( - "index.html", - rows=rows, - error=error, - radarr_url=RADARR_URL, - radarr_media_path=RADARR_MEDIA_PATH, - local_media_path=LOCAL_MEDIA_PATH, - ) + rows = radarr_rows() + if any(row["pending"] for row in rows): start_scan("radarr") + except Exception as exc: error = str(exc) + if SONARR_URL and SONARR_API_KEY: + try: + series = sonarr_groups() + if any(row["pending"] for group in series for row in group["episodes"]): start_scan("sonarr") + except Exception as exc: log.exception("Sonarr nicht erreichbar"); error = f"{error + ' | ' if error else ''}Sonarr: {exc}" + return render_template("index.html", rows=rows, series=series, error=error, sonarr_enabled=bool(SONARR_URL and SONARR_API_KEY), radarr_media_path=RADARR_MEDIA_PATH, local_media_path=LOCAL_MEDIA_PATH, local_series_path=LOCAL_SERIES_PATH) @app.post("/api/rescan") def api_rescan(): + source = request.args.get("source", "radarr") + if source not in jobs: return jsonify({"ok":False, "error":"Unbekannte Quelle"}), 400 con = db() - con.execute("DELETE FROM media_cache") - con.commit() - con.close() - log.info("Cache geleert") - return jsonify({"ok": True}) + if source == "radarr": con.execute("DELETE FROM media_cache") + else: con.execute("DELETE FROM media_cache WHERE path LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",)) + con.commit(); con.close(); start_scan(source, force=True) + return jsonify({"ok":True}) + +@app.get("/api/scan-status") +def scan_status(): + with job_lock: return jsonify(jobs) @app.get("/health") -def health(): - return jsonify({ - "ok": True, - "radarr_url": RADARR_URL, - "radarr_media_path": RADARR_MEDIA_PATH, - "local_media_path": LOCAL_MEDIA_PATH, - }) +def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY)}) -if __name__ == "__main__": - app.run(host="0.0.0.0", port=8099) +if __name__ == "__main__": app.run(host="0.0.0.0", port=8099) diff --git a/docker-compose.yml b/docker-compose.yml index 21faf6a..bd4aa3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,17 +9,24 @@ services: RADARR_URL: "http://radarr:7878" RADARR_API_KEY: "CHANGE_ME" + # Optional: Sonarr aktivieren (auskommentieren und Werte anpassen) + # SONARR_URL: "http://sonarr:8989" + # SONARR_API_KEY: "CHANGE_ME" + # Pfad, den Radarr in seiner API meldet: RADARR_MEDIA_PATH: "/data/filme" # Pfad derselben Dateien INNERHALB dieses Containers: LOCAL_MEDIA_PATH: "/media/filme" + # SONARR_MEDIA_PATH: "/data/serien" + # LOCAL_SERIES_PATH: "/media/serien" DB_PATH: "/data/cache.db" LOG_LEVEL: "INFO" volumes: # Host-Pfad deiner Film-Library -> interner Dashboard-Pfad - /nesflix/filme:/media/filme:ro + # - /nesflix/serien:/media/serien:ro - ./data:/data networks: - media diff --git a/templates/index.html b/templates/index.html index e070871..06a0aea 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,311 +1,27 @@ - - - - -Radarr Language Dashboard +Language Dashboard - - -
- -
-
Language Dashboard
-
Audio- und Untertitelspuren aus den Mediendateien
-
-
-
- +:root{color-scheme:dark;--bg:#181818;--panel:#222;--line:#3a3a3a;--text:#dedede;--muted:#929292;--yellow:#f4c430;--blue:#60a9e6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:13px Inter,system-ui,sans-serif}header{height:58px;display:flex;align-items:center;gap:15px;padding:0 20px;background:#242424;border-bottom:1px solid #343434}.logo{color:var(--yellow);font-size:22px;font-weight:900;letter-spacing:1px}.head-title{font-weight:700}.head-sub{font-size:12px;color:var(--muted);margin-top:2px}.actions{margin-left:auto}button,input,select{font:inherit;color:var(--text);background:#292929;border:1px solid #444;border-radius:5px}button{padding:7px 10px;cursor:pointer}main{padding:14px 20px 30px}.nav{display:flex;gap:4px;margin-bottom:12px;border-bottom:1px solid var(--line)}.nav button{border:0;border-radius:5px 5px 0 0;background:transparent;color:#aaa}.nav button.active{background:#303030;color:#fff}.view{display:none}.view.active{display:block}.toolbar{display:grid;grid-template-columns:minmax(280px,1fr) 190px 190px 170px;gap:8px;margin-bottom:10px}input,select{padding:7px 10px;height:34px}.stats{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap}.stat{background:#242424;border:1px solid #363636;padding:5px 9px;border-radius:5px;color:#bbb}.scan{color:#f4c430}.banner{padding:9px 11px;border:1px solid #693b3b;background:#372424;border-radius:5px;margin-bottom:10px;color:#ffd1d1}.tablewrap{border:1px solid #353535;border-radius:6px;overflow:hidden;background:var(--panel)}table{width:100%;border-collapse:collapse;table-layout:fixed}th{padding:8px 10px;background:#282828;border-bottom:1px solid #444;text-align:left;font-size:12px}td{padding:7px 10px;border-bottom:1px solid #343434;vertical-align:middle}.col-title{width:28%}.col-quality{width:12%}.col-state{width:10%}.col-audio{width:17%}.col-subs{width:17%}.col-video{width:8%}.col-size{width:8%}.title{color:var(--blue);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;max-width:90%;vertical-align:bottom}.year{color:var(--muted);font-size:11px;margin-left:5px}.badges{display:flex;gap:4px;flex-wrap:wrap}.badge{display:inline-flex;gap:4px;align-items:center;padding:2px 6px;border-radius:999px;border:1px solid #484848;background:#303030;white-space:nowrap;font-size:11px}.quality{background:#0e7138;border-color:#19894b}.state{font-size:11px;font-weight:700;padding:3px 7px;border-radius:4px;display:inline-block}.state.ok{background:#126f39;color:#d9ffe8}.state.warn{background:#77561e;color:#ffebc4}.state.bad{background:#763030;color:#ffd9d9}.state.neutral{background:#3b3b3b;color:#ccc}.muted{color:var(--muted)}.hidden{display:none}.series{border-bottom:1px solid var(--line)}.series:last-child{border:0}.series summary{padding:12px;cursor:pointer;background:#282828;font-weight:700}.series summary:hover{background:#303030}.series table{border-radius:0}.series td:first-child{padding-left:28px}@media(max-width:1100px){.toolbar{grid-template-columns:1fr 1fr}.col-video,.col-size{display:none}} + +
Language Dashboard
Audio- und Untertitelspuren aus den Mediendateien
-{% if error %} - -{% endif %} - -
- - - - -
- -
- - Radarr: {{ radarr_media_path }} - Dashboard: {{ local_media_path }} -
- -
- - - - - - - - - - - - - -{% for r in rows %} - - - - - - - - - -{% endfor %} - -
MovieQualityStatusAudioUntertitelVideoGröße
- {{ r.title }} - {{ r.year or '' }} - {% if r.error %} - - {% endif %} - - {% if r.missing %} - Keine Datei - {% else %} - {{ r.quality }} - {% endif %} - {{ r.state.label }} -
- {% for lang in r.audio_languages %} - {{ lang.flag }} {{ lang.name }} - {% else %}{% endfor %} -
-
-
- {% for lang in r.subtitle_languages %} - {{ lang.flag }} {{ lang.name }} - {% else %}{% endfor %} -
-
{{ r.video_codec }}{{ r.size }}
-
+{% if error %}{% 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 group in 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 %}
- - - +const rows=[...document.querySelectorAll('#movies tbody tr')], search=document.querySelector('#search'), audio=document.querySelector('#audioFilter'), subs=document.querySelector('#subFilter'), state=document.querySelector('#stateFilter'), count=document.querySelector('#visibleCount'); +function apply(){const q=search.value.trim().toLowerCase();let n=0;rows.forEach(r=>{const ok=(!q||r.dataset.title.includes(q))&&(!audio.value||r.dataset.audio.includes(audio.value))&&(!subs.value||r.dataset.subs.includes(subs.value))&&(!state.value||r.dataset.state===state.value);r.classList.toggle('hidden',!ok);if(ok)n++});count.textContent=`${n} Filme`};[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));apply(); +document.querySelectorAll('.nav button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.nav button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));b.classList.add('active');document.querySelector('#'+b.dataset.view).classList.add('active');document.querySelector('#rescan').dataset.source=b.dataset.view}); +function statusText(s){if(s.state==='running')return `Scan: ${s.done}/${s.total||'?'} Dateien`;if(s.state==='done')return 'Scan abgeschlossen';if(s.state==='error')return 'Scanfehler: '+s.error;return 'Scan wird vorbereitet …'} +const seenRunning={radarr:false,sonarr:false};async function poll(){try{const data=await (await fetch('/api/scan-status')).json();document.querySelector('#radarrStatus').textContent=statusText(data.radarr);const sonarr=document.querySelector('#sonarrStatus');if(sonarr)sonarr.textContent=statusText(data.sonarr);['radarr','sonarr'].forEach(source=>{if(data[source].state==='running')seenRunning[source]=true;if(seenRunning[source]&&['done','error'].includes(data[source].state)){seenRunning[source]=false;setTimeout(()=>location.reload(),700)}})}catch(e){} };setInterval(poll,1200);poll(); +document.querySelector('#rescan').onclick=async()=>{const b=document.querySelector('#rescan');b.disabled=true;b.textContent='Scanne …';await fetch('/api/rescan?source='+(b.dataset.source||'radarr'),{method:'POST'});b.disabled=false;b.textContent='↻ Neu scannen';poll()}; +