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.
296 lines
8.7 KiB
Python
296 lines
8.7 KiB
Python
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)
|