From 6ee9610b11eb50267c63475276a0fc4403f4ffa5 Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 13 Aug 2026 10:46:45 +0200 Subject: [PATCH] feat: add Radarr language dashboard with Docker support Add a Flask-based dashboard for monitoring audio/subtitle languages in Radarr media files. Includes ffprobe integration for media analysis, SQLite caching, path mapping between Radarr and host filesystems, and a responsive web UI with filtering and statistics. --- Dockerfile | 11 ++ README.md | 46 +++++++ app.py | 295 ++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 30 +++++ requirements.txt | 3 + templates/index.html | 311 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 696 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 templates/index.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed4c950 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.13-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY requirements.txt . +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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8f9708 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Radarr Language Dashboard v2 + +## Wichtig: Path-Mapping + +Radarr kann seine Filme z.B. unter `/data/filme` sehen, während der Host sie unter +`/nesflix/filme` hat. Das Dashboard unterstützt diese Abbildung explizit: + +```yaml +environment: + RADARR_MEDIA_PATH: "/data/filme" + LOCAL_MEDIA_PATH: "/media/filme" + +volumes: + - /nesflix/filme:/media/filme:ro +``` + +Ein Radarr-Pfad wie: + +`/data/filme/Avatar (2009)/Avatar.mkv` + +wird dadurch für ffprobe zu: + +`/media/filme/Avatar (2009)/Avatar.mkv` + +## Start + +API-Key und ggf. Docker-Netz in `docker-compose.yml` anpassen: + +```bash +docker compose down +docker compose up -d --build +docker compose logs -f +``` + +Beim ersten Laden erscheinen im Log `Gescannt:`-Einträge. + +## Bei weiterem Scanfehler + +Prüfe einen Pfad im Container: + +```bash +docker exec -it radarr-language-dashboard sh +ls -lah /media/filme +find /media/filme -type f | head +ffprobe -v error -show_entries stream=codec_type,codec_name:stream_tags=language -of json "/media/filme/DEIN/FILM.mkv" +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..a5f5847 --- /dev/null +++ b/app.py @@ -0,0 +1,295 @@ +import json +import logging +import os +import sqlite3 +import subprocess +from pathlib import Path +from datetime import datetime + +import requests +from flask import Flask, render_template, jsonify + +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("/") +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", +) +log = logging.getLogger("radarr-language-dashboard") + +app = Flask(__name__) + +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 + ) + """) + 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, + ) + r.raise_for_status() + return r.json() + +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, "🌐")} + +def unique_langs(items): + seen, out = set(), [] + for item in items: + key = item["name"] + if key not in seen: + seen.add(key) + 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) + 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") + + 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): + p = Path(path) + if not p.exists(): + msg = f"Datei nicht gefunden: {p}" + log.warning(msg) + return {"error": msg} + + 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() + return data + +def format_size(size): + 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}" + 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 library(): + movies = radarr_get("movie") + rows = [] + + for movie in movies: + mf = movie.get("movieFile") + + 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 + +@app.route("/") +def index(): + error = None + rows = [] + 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, + ) + +@app.post("/api/rescan") +def api_rescan(): + con = db() + con.execute("DELETE FROM media_cache") + con.commit() + con.close() + log.info("Cache geleert") + return jsonify({"ok": True}) + +@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, + }) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8099) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..21faf6a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + radarr-language-dashboard: + build: . + container_name: radarr-language-dashboard + restart: unless-stopped + ports: + - "8099:8099" + environment: + RADARR_URL: "http://radarr:7878" + RADARR_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" + + DB_PATH: "/data/cache.db" + LOG_LEVEL: "INFO" + volumes: + # Host-Pfad deiner Film-Library -> interner Dashboard-Pfad + - /nesflix/filme:/media/filme:ro + - ./data:/data + networks: + - media + +networks: + media: + external: true + name: media-stack_default diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8250cc5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.1.1 +gunicorn==23.0.0 +requests==2.32.4 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..e070871 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,311 @@ + + + + + +Radarr Language Dashboard + + + +
+ +
+
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 }}
+
+
+ + + +