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.
This commit is contained in:
2026-08-13 10:46:45 +02:00
commit 6ee9610b11
6 changed files with 696 additions and 0 deletions
+11
View File
@@ -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"]
+46
View File
@@ -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"
```
+295
View File
@@ -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)
+30
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
Flask==3.1.1
gunicorn==23.0.0
requests==2.32.4
+311
View File
@@ -0,0 +1,311 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Radarr Language Dashboard</title>
<style>
:root{
color-scheme:dark;
--bg:#181818;
--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>
{% if error %}
<div class="banner"><strong>Fehler:</strong> {{ error }}</div>
{% endif %}
<div class="toolbar">
<input id="search" type="search" placeholder="Film suchen …">
<select id="audioFilter">
<option value="">Alle Audio-Sprachen</option>
<option value="Deutsch">🇩🇪 Deutsch</option>
<option value="Englisch">🇬🇧 Englisch</option>
<option value="Japanisch">🇯🇵 Japanisch</option>
<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>
<script>
const rows=[...document.querySelectorAll('#movies tbody tr')];
const search=document.querySelector('#search');
const audio=document.querySelector('#audioFilter');
const subs=document.querySelector('#subFilter');
const state=document.querySelector('#stateFilter');
const count=document.querySelector('#visibleCount');
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>