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)