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:
2026-08-13 10:49:39 +02:00
parent 6ee9610b11
commit acbf3215bb
5 changed files with 169 additions and 537 deletions
+129 -229
View File
@@ -3,75 +3,46 @@ import logging
import os
import sqlite3
import subprocess
from pathlib import Path
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
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_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("/")
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")
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",
)
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__)
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 = {
"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": "",
}
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
)
""")
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,
)
def api_get(base, key, endpoint):
if not key:
raise RuntimeError(f"API-Key für {base} ist nicht gesetzt.")
r = requests.get(f"{base}/api/v3/{endpoint.lstrip('/')}", headers={"X-Api-Key": key}, timeout=REQUEST_TIMEOUT)
r.raise_for_status()
return r.json()
@@ -81,215 +52,144 @@ def norm_lang(code):
return {"code": code or "und", "name": name, "flag": FLAGS.get(name, "🌐")}
def unique_langs(items):
seen, out = set(), []
out, seen = [], set()
for item in items:
key = item["name"]
if key not in seen:
seen.add(key)
out.append(item)
if item["name"] not in seen:
seen.add(item["name"]); 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)
def map_path(path, remote, local):
if not path: return None
if remote and path.startswith(remote):
return str(Path(local) / path[len(remote):].lstrip("/"))
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")
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")
raw, audios, subs, details, video = json.loads(proc.stdout or "{}"), [], [], [], None
for stream in raw.get("streams", []):
tags = stream.get("tags") or {}; lang = norm_lang(tags.get("language")); kind = stream.get("codec_type")
if kind == "video" and not video: video = stream.get("codec_name")
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")})
elif kind == "subtitle": subs.append(lang)
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)}
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):
def cached(path):
p = Path(path)
if not p.exists():
msg = f"Datei nicht gefunden: {p}"
log.warning(msg)
return {"error": msg}
if not p.exists(): return {"error": 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()
if row and float(row["mtime"]) == float(mtime): return json.loads(row["data"])
return None
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()
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
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()
return data
def format_size(size):
if not size:
return ""
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}"
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 language_state(audios):
names = {x["name"] for x in audios}
if {"Deutsch", "Englisch"} <= 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")
def media_row(name, year, media_file, remote, local, extra=None):
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 = []
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:
mf = movie.get("movieFile")
def sonarr_groups():
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:
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
def start_scan(source, force=False):
with job_lock:
if jobs[source]["state"] == "running" or (jobs[source]["state"] in ("done", "error") and not force): return
jobs[source] = {"state":"running", "total":0, "done":0, "error":None}
def work():
try:
paths = []
if source == "radarr":
for row in radarr_rows():
if row["local_path"] != "" and row["pending"]: paths.append(row["local_path"])
else:
for group in sonarr_groups():
paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "" and r["pending"])
with job_lock: jobs[source]["total"] = len(paths)
for path in paths:
scan_one(path)
with job_lock: jobs[source]["done"] += 1
with job_lock: jobs[source]["state"] = "done"
except Exception as exc:
log.exception("%s-Scan fehlgeschlagen", source); jobs[source]["error"] = str(exc); jobs[source]["state"] = "error"
executor.submit(work)
@app.route("/")
def index():
error = None
rows = []
error = None; rows = []; series = []
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,
)
rows = radarr_rows()
if any(row["pending"] for row in rows): start_scan("radarr")
except Exception as exc: error = str(exc)
if SONARR_URL and SONARR_API_KEY:
try:
series = sonarr_groups()
if any(row["pending"] for group in series for row in group["episodes"]): start_scan("sonarr")
except Exception as exc: log.exception("Sonarr nicht erreichbar"); error = f"{error + ' | ' if error else ''}Sonarr: {exc}"
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)
@app.post("/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.execute("DELETE FROM media_cache")
con.commit()
con.close()
log.info("Cache geleert")
return jsonify({"ok": True})
if source == "radarr": con.execute("DELETE FROM media_cache")
else: con.execute("DELETE FROM media_cache WHERE path LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",))
con.commit(); con.close(); start_scan(source, force=True)
return jsonify({"ok":True})
@app.get("/api/scan-status")
def scan_status():
with job_lock: return jsonify(jobs)
@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,
})
def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY)})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8099)
if __name__ == "__main__": app.run(host="0.0.0.0", port=8099)