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.
This commit is contained in:
nessi committed 2026-08-13 10:49:39 +02:00
1 parent 6ee9610b11
commit acbf3215bb
5 files changed
+168 -536

No files matched your search

+1 -1
View File
@@ -8,4 +8,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app.py . COPY app.py .
COPY templates ./templates COPY templates ./templates
EXPOSE 8099 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"]
+10 -1
View File
@@ -32,7 +32,16 @@ docker compose up -d --build
docker compose logs -f 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 ## Bei weiterem Scanfehler
+128 -228
View File
@@ -3,75 +3,46 @@ import logging
import os import os
import sqlite3 import sqlite3
import subprocess import subprocess
from pathlib import Path import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime
from pathlib import Path
import requests 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_URL = os.environ.get("RADARR_URL", "http://radarr:7878").rstrip("/")
RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "")
RADARR_MEDIA_PATH = os.environ.get("RADARR_MEDIA_PATH", "/data/filme").rstrip("/") RADARR_MEDIA_PATH = os.environ.get("RADARR_MEDIA_PATH", "/data/filme").rstrip("/")
LOCAL_MEDIA_PATH = os.environ.get("LOCAL_MEDIA_PATH", "/media/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") DB_PATH = os.environ.get("DB_PATH", "/data/cache.db")
REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "20")) REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "20"))
logging.basicConfig( logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s")
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("radarr-language-dashboard") log = logging.getLogger("radarr-language-dashboard")
app = Flask(__name__) 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 = { 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"}
"de": "Deutsch", "deu": "Deutsch", "ger": "Deutsch", FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":"❓"}
"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(): def db():
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH) con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
con.execute(""" 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)")
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 return con
def radarr_get(endpoint): def api_get(base, key, endpoint):
if not RADARR_API_KEY: if not key:
raise RuntimeError("RADARR_API_KEY ist nicht gesetzt.") raise RuntimeError(f"API-Key für {base} ist nicht gesetzt.")
r = requests.get( r = requests.get(f"{base}/api/v3/{endpoint.lstrip('/')}", headers={"X-Api-Key": key}, timeout=REQUEST_TIMEOUT)
f"{RADARR_URL}/api/v3/{endpoint.lstrip('/')}",
headers={"X-Api-Key": RADARR_API_KEY},
timeout=REQUEST_TIMEOUT,
)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -81,215 +52,144 @@ def norm_lang(code):
return {"code": code or "und", "name": name, "flag": FLAGS.get(name, "🌐")} return {"code": code or "und", "name": name, "flag": FLAGS.get(name, "🌐")}
def unique_langs(items): def unique_langs(items):
seen, out = set(), [] out, seen = [], set()
for item in items: for item in items:
key = item["name"] if item["name"] not in seen:
if key not in seen: seen.add(item["name"]); out.append(item)
seen.add(key)
out.append(item)
return out return out
def map_radarr_path(path): def map_path(path, remote, local):
if not path: if not path: return None
return None if remote and path.startswith(remote):
if RADARR_MEDIA_PATH and path.startswith(RADARR_MEDIA_PATH): return str(Path(local) / path[len(remote):].lstrip("/"))
suffix = path[len(RADARR_MEDIA_PATH):].lstrip("/")
return str(Path(LOCAL_MEDIA_PATH) / suffix)
return path return path
def run_ffprobe(path): def run_ffprobe(path):
proc = subprocess.run( 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")
"ffprobe", "-v", "error", raw, audios, subs, details, video = json.loads(proc.stdout or "{}"), [], [], [], None
"-show_entries", for stream in raw.get("streams", []):
"format=duration,size,bit_rate:stream=index,codec_type,codec_name,profile,channels,channel_layout:stream_tags=language,title", tags = stream.get("tags") or {}; lang = norm_lang(tags.get("language")); kind = stream.get("codec_type")
"-of", "json", if kind == "video" and not video: video = stream.get("codec_name")
path, 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")})
capture_output=True, elif kind == "subtitle": subs.append(lang)
text=True, 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)}
timeout=90,
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or "ffprobe fehlgeschlagen")
raw = json.loads(proc.stdout or "{}") def cached(path):
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):
p = Path(path) p = Path(path)
if not p.exists(): if not p.exists(): return {"error": f"Datei nicht gefunden: {p}"}
msg = 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()
log.warning(msg) if row and float(row["mtime"]) == float(mtime): return json.loads(row["data"])
return {"error": msg} return None
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 mtime = p.stat().st_mtime
con = db() 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()
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()
return data return data
def format_size(size): def format_size(size):
if not size: if not size: return "—"
return "—"
value = float(size) value = float(size)
for unit in ["B", "KB", "MB", "GB", "TB"]: for unit in ["B", "KB", "MB", "GB", "TB"]:
if value < 1024 or unit == "TB": if value < 1024 or unit == "TB": return f"{value:.1f} {unit}"
return f"{value:.1f} {unit}"
value /= 1024 value /= 1024
def language_state(audio_languages): def language_state(audios):
names = {x["name"] for x in audio_languages} names = {x["name"] for x in audios}
if "Deutsch" in names and "Englisch" in names: if {"Deutsch", "Englisch"} <= names: return {"class":"ok", "label":"DE + EN"}
return {"class": "ok", "label": "DE + EN"} if "Deutsch" in names: return {"class":"warn", "label":"Deutsch"}
if "Deutsch" in names: if "Englisch" in names: return {"class":"bad", "label":"English only"}
return {"class": "warn", "label": "Deutsch"} return {"class":"neutral", "label":"Andere"}
if "Englisch" in names:
return {"class": "bad", "label": "English only"}
return {"class": "neutral", "label": "Andere"}
def library(): def media_row(name, year, media_file, remote, local, extra=None):
movies = radarr_get("movie") 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 = [] 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: def sonarr_groups():
mf = movie.get("movieFile") 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: def start_scan(source, force=False):
rows.append({ with job_lock:
"title": movie.get("title", "—"), if jobs[source]["state"] == "running" or (jobs[source]["state"] in ("done", "error") and not force): return
"year": movie.get("year"), jobs[source] = {"state":"running", "total":0, "done":0, "error":None}
"quality": "Keine Datei", def work():
"audio_languages": [], try:
"subtitle_languages": [], paths = []
"video_codec": "—", if source == "radarr":
"size": "—", for row in radarr_rows():
"radarr_path": "—", if row["local_path"] != "—" and row["pending"]: paths.append(row["local_path"])
"local_path": "—", else:
"state": {"class": "neutral", "label": "Keine Datei"}, for group in sonarr_groups():
"error": None, paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "—" and r["pending"])
"missing": True, with job_lock: jobs[source]["total"] = len(paths)
}) for path in paths:
continue scan_one(path)
with job_lock: jobs[source]["done"] += 1
radarr_path = mf.get("path") with job_lock: jobs[source]["state"] = "done"
if not radarr_path and mf.get("relativePath"): except Exception as exc:
radarr_path = str(Path(movie.get("path") or "") / mf["relativePath"]) log.exception("%s-Scan fehlgeschlagen", source); jobs[source]["error"] = str(exc); jobs[source]["state"] = "error"
executor.submit(work)
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
@app.route("/") @app.route("/")
def index(): def index():
error = None error = None; rows = []; series = []
rows = []
try: try:
rows = library() rows = radarr_rows()
except Exception as e: if any(row["pending"] for row in rows): start_scan("radarr")
log.exception("Fehler beim Laden der Library") except Exception as exc: error = str(exc)
error = str(e) if SONARR_URL and SONARR_API_KEY:
try:
return render_template( series = sonarr_groups()
"index.html", if any(row["pending"] for group in series for row in group["episodes"]): start_scan("sonarr")
rows=rows, except Exception as exc: log.exception("Sonarr nicht erreichbar"); error = f"{error + ' | ' if error else ''}Sonarr: {exc}"
error=error, 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)
radarr_url=RADARR_URL,
radarr_media_path=RADARR_MEDIA_PATH,
local_media_path=LOCAL_MEDIA_PATH,
)
@app.post("/api/rescan") @app.post("/api/rescan")
def 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 = db()
con.execute("DELETE FROM media_cache") if source == "radarr": con.execute("DELETE FROM media_cache")
con.commit() else: con.execute("DELETE FROM media_cache WHERE path LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",))
con.close() con.commit(); con.close(); start_scan(source, force=True)
log.info("Cache geleert") return jsonify({"ok":True})
return jsonify({"ok": True})
@app.get("/api/scan-status")
def scan_status():
with job_lock: return jsonify(jobs)
@app.get("/health") @app.get("/health")
def health(): def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY)})
return jsonify({
"ok": True,
"radarr_url": RADARR_URL,
"radarr_media_path": RADARR_MEDIA_PATH,
"local_media_path": LOCAL_MEDIA_PATH,
})
if __name__ == "__main__": if __name__ == "__main__": app.run(host="0.0.0.0", port=8099)
app.run(host="0.0.0.0", port=8099)
+7
View File
@@ -9,17 +9,24 @@ services:
RADARR_URL: "http://radarr:7878" RADARR_URL: "http://radarr:7878"
RADARR_API_KEY: "CHANGE_ME" 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: # Pfad, den Radarr in seiner API meldet:
RADARR_MEDIA_PATH: "/data/filme" RADARR_MEDIA_PATH: "/data/filme"
# Pfad derselben Dateien INNERHALB dieses Containers: # Pfad derselben Dateien INNERHALB dieses Containers:
LOCAL_MEDIA_PATH: "/media/filme" LOCAL_MEDIA_PATH: "/media/filme"
# SONARR_MEDIA_PATH: "/data/serien"
# LOCAL_SERIES_PATH: "/media/serien"
DB_PATH: "/data/cache.db" DB_PATH: "/data/cache.db"
LOG_LEVEL: "INFO" LOG_LEVEL: "INFO"
volumes: volumes:
# Host-Pfad deiner Film-Library -> interner Dashboard-Pfad # Host-Pfad deiner Film-Library -> interner Dashboard-Pfad
- /nesflix/filme:/media/filme:ro - /nesflix/filme:/media/filme:ro
# - /nesflix/serien:/media/serien:ro
- ./data:/data - ./data:/data
networks: networks:
- media - media
+22 -306
View File
@@ -1,311 +1,27 @@
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Language Dashboard</title>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Radarr Language Dashboard</title>
<style> <style>
:root{ :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}}
color-scheme:dark; </style></head><body>
--bg:#181818; <header><div class="logo">MEDIA</div><div><div class="head-title">Language Dashboard</div><div class="head-sub">Audio- und Untertitelspuren aus den Mediendateien</div></div><div class="actions"><button id="rescan">↻ Neu scannen</button></div></header>
--panel:#202020;
--panel2:#262626;
--line:#373737;
--text:#dedede;
--muted:#929292;
--yellow:#f4c430;
--blue:#60a9e6;
--green:#159447;
--red:#bd4d4d;
--orange:#ba812d;
}
*{box-sizing:border-box}
body{
margin:0;
background:var(--bg);
color:var(--text);
font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif;
font-size:13px;
}
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;font-size:14px}
.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}
button:hover{border-color:#777}
main{padding:14px 20px 30px}
.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;
}
.tablewrap{
border:1px solid #353535;
border-radius:6px;
overflow:hidden;
background:#202020;
}
table{
width:100%;
border-collapse:collapse;
table-layout:fixed;
}
thead th{
padding:8px 10px;
background:#282828;
border-bottom:1px solid #444;
color:#cfcfcf;
font-size:12px;
font-weight:650;
text-align:left;
position:sticky;
top:0;
}
tbody td{
padding:7px 10px;
border-bottom:1px solid #343434;
vertical-align:middle;
}
tbody tr:hover{background:#252525}
tbody tr:last-child td{border-bottom:0}
.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;
}
.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;
color:#fff;
}
.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)}
.error-icon{
color:#ff7777;
cursor:help;
font-size:12px;
margin-left:6px;
}
.banner{
padding:9px 11px;
border:1px solid #693b3b;
background:#372424;
border-radius:5px;
margin-bottom:10px;
color:#ffd1d1;
}
.hidden{display:none}
@media(max-width:1100px){
.toolbar{grid-template-columns:1fr 1fr}
.col-video,.col-size{display:none}
}
</style>
</head>
<body>
<header>
<div class="logo">RADARR</div>
<div>
<div class="head-title">Language Dashboard</div>
<div class="head-sub">Audio- und Untertitelspuren aus den Mediendateien</div>
</div>
<div class="actions"><button id="rescan">↻ Neu scannen</button></div>
</header>
<main> <main>
{% if error %} {% if error %}<div class="banner"><strong>Fehler:</strong> {{ error }}</div>{% endif %}
<div class="banner"><strong>Fehler:</strong> {{ error }}</div> <nav class="nav"><button class="active" data-view="radarr">RADARR · Filme</button>{% if sonarr_enabled %}<button data-view="sonarr">SONARR · Serien</button>{% endif %}</nav>
{% endif %} <section id="radarr" class="view active" data-source="radarr">
<div class="toolbar"><input id="search" type="search" placeholder="Film suchen …"><select id="audioFilter"><option value="">Alle Audio-Sprachen</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="subFilter"><option value="">Alle Untertitel</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="stateFilter"><option value="">Alle Status</option><option>DE + EN</option><option>Deutsch</option><option>English only</option></select></div>
<div class="toolbar"> <div class="stats"><span class="stat" id="visibleCount"></span><span class="stat scan" id="radarrStatus">Scan wird vorbereitet …</span><span class="stat">Radarr: {{ radarr_media_path }}</span></div>
<input id="search" type="search" placeholder="Film suchen …"> <div class="tablewrap"><table id="movies"><thead><tr><th class="col-title">Film</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>
<select id="audioFilter"> {% for r in rows %}<tr data-title="{{ (r.title ~ ' ' ~ (r.year or ''))|lower }}" data-audio="{{ r.audio_languages|map(attribute='name')|join(',') }}" data-subs="{{ r.subtitle_languages|map(attribute='name')|join(',') }}" data-state="{{ r.state.label }}"><td><span class="title">{{ r.title }}</span><span class="year">{{ r.year or '' }}</span>{% if r.error %} ⚠{% endif %}</td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}
<option value="">Alle Audio-Sprachen</option> </tbody></table></div></section>
<option value="Deutsch">🇩🇪 Deutsch</option> {% if sonarr_enabled %}<section id="sonarr" class="view" data-source="sonarr"><div class="stats"><span class="stat" id="seriesCount">{{ series|length }} Serien</span><span class="stat scan" id="sonarrStatus">Scan wird vorbereitet …</span><span class="stat">Dashboard: {{ local_series_path }}</span></div><div class="tablewrap" id="seriesList">
<option value="Englisch">🇬🇧 Englisch</option> {% for group in series %}<details class="series" open><summary>{{ group.title }} <span class="muted">({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden</span></summary><table><thead><tr><th class="col-title">Episode</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in group.episodes %}<tr><td><span class="title">{{ r.title }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table></details>{% endfor %}
<option value="Japanisch">🇯🇵 Japanisch</option> </div></section>{% endif %}
<option value="Koreanisch">🇰🇷 Koreanisch</option>
</select>
<select id="subFilter">
<option value="">Alle Untertitel</option>
<option value="Deutsch">🇩🇪 Deutsch</option>
<option value="Englisch">🇬🇧 Englisch</option>
<option value="Japanisch">🇯🇵 Japanisch</option>
<option value="Koreanisch">🇰🇷 Koreanisch</option>
</select>
<select id="stateFilter">
<option value="">Alle Status</option>
<option value="DE + EN">DE + EN</option>
<option value="Deutsch">Deutsch</option>
<option value="English only">English only</option>
</select>
</div>
<div class="stats">
<span class="stat" id="visibleCount"></span>
<span class="stat">Radarr: {{ radarr_media_path }}</span>
<span class="stat">Dashboard: {{ local_media_path }}</span>
</div>
<div class="tablewrap">
<table id="movies">
<thead>
<tr>
<th class="col-title">Movie</th>
<th class="col-quality">Quality</th>
<th class="col-state">Status</th>
<th class="col-audio">Audio</th>
<th class="col-subs">Untertitel</th>
<th class="col-video">Video</th>
<th class="col-size">Größe</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr
data-title="{{ (r.title ~ ' ' ~ (r.year or ''))|lower }}"
data-audio="{{ r.audio_languages|map(attribute='name')|join(',') }}"
data-subs="{{ r.subtitle_languages|map(attribute='name')|join(',') }}"
data-state="{{ r.state.label }}"
>
<td>
<span class="title">{{ r.title }}</span>
<span class="year">{{ r.year or '' }}</span>
{% if r.error %}
<span class="error-icon" title="{{ r.error }}">⚠</span>
{% endif %}
</td>
<td>
{% if r.missing %}
<span class="badge">Keine Datei</span>
{% else %}
<span class="badge quality">{{ r.quality }}</span>
{% endif %}
</td>
<td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td>
<td>
<div class="badges">
{% for lang in r.audio_languages %}
<span class="badge">{{ lang.flag }} {{ lang.name }}</span>
{% else %}<span class="muted">—</span>{% endfor %}
</div>
</td>
<td>
<div class="badges">
{% for lang in r.subtitle_languages %}
<span class="badge">{{ lang.flag }} {{ lang.name }}</span>
{% else %}<span class="muted">—</span>{% endfor %}
</div>
</td>
<td class="col-video">{{ r.video_codec }}</td>
<td class="col-size">{{ r.size }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</main> </main>
<script> <script>
const rows=[...document.querySelectorAll('#movies tbody tr')]; 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');
const search=document.querySelector('#search'); function apply(){const q=search.value.trim().toLowerCase();let n=0;rows.forEach(r=>{const ok=(!q||r.dataset.title.includes(q))&&(!audio.value||r.dataset.audio.includes(audio.value))&&(!subs.value||r.dataset.subs.includes(subs.value))&&(!state.value||r.dataset.state===state.value);r.classList.toggle('hidden',!ok);if(ok)n++});count.textContent=`${n} Filme`};[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));apply();
const audio=document.querySelector('#audioFilter'); 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});
const subs=document.querySelector('#subFilter'); 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 state=document.querySelector('#stateFilter'); 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();
const count=document.querySelector('#visibleCount'); 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()};
</script></body></html>
function apply(){
const q=search.value.trim().toLowerCase();
let visible=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) visible++;
});
count.textContent=`${visible} Filme`;
}
[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));
apply();
document.querySelector('#rescan').addEventListener('click',async()=>{
const b=document.querySelector('#rescan');
b.disabled=true;b.textContent='Scanne …';
try{
await fetch('/api/rescan',{method:'POST'});
location.reload();
}finally{b.disabled=false}
});
</script>
</body>
</html>