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
-
-
-
-
+: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}}
+
+
-{% if error %}
- Fehler: {{ error }}
-{% endif %}
-
-
-
-
-
-
-
-
-
-
- Radarr: {{ radarr_media_path }}
- Dashboard: {{ local_media_path }}
-
-
-
-
-
-
- | Movie |
- Quality |
- Status |
- Audio |
- Untertitel |
- Video |
- Größe |
-
-
-
-{% for r in rows %}
-
- |
- {{ 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 }} |
-
-{% endfor %}
-
-
-
+{% if error %}Fehler: {{ error }}
{% endif %}
+
+
+
+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 %}{{ series|length }} SerienScan wird vorbereitet …Dashboard: {{ local_series_path }}
+{% for group in 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 %}
-
-
-
+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()};
+