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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user