From f78944795c9a1b96a8ab0c5a981ed812a81c9820 Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 13 Aug 2026 11:00:57 +0200 Subject: [PATCH] feat: add SABnzbd download monitoring with language detection and source tracking Add SABnzbd integration to display active and completed downloads with progress tracking, speed monitoring, and automatic language detection from release names. Downloads are matched against Radarr/Sonarr queues to identify source application. Add new Downloads tab in navigation, API endpoint for queue/history data, and auto-refresh every 4 seconds. Include configuration for SAB_URL and SAB_API_KEY in docker-compose and --- README.md | 9 +++++++ app.py | 60 ++++++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 4 +++ templates/index.html | 16 +++++++++++- 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 82bcb9e..7f177be 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,15 @@ SONARR_MEDIA_PATH: "/data/serien" LOCAL_SERIES_PATH: "/media/serien" ``` +SABnzbd benötigt für den Download-Tab: + +```yaml +SAB_URL: "http://sabnzbd:8080" +SAB_API_KEY: "..." +``` + +Der API-Key steht in SABnzbd unter `Config > General`. Die laufenden Jobs kommen aus der SABnzbd-Queue, abgeschlossene und fehlgeschlagene Jobs aus der History. Die Sprache eines laufenden Releases wird aus dessen Namen abgeleitet; nach dem Import ist die Sprache im Radarr-/Sonarr-Scan verlässlich. + ## Bei weiterem Scanfehler Prüfe einen Pfad im Container: diff --git a/app.py b/app.py index 02b1c34..0d7a194 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,8 @@ 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("/") +SAB_URL = os.environ.get("SAB_URL", "").rstrip("/") +SAB_API_KEY = os.environ.get("SAB_API_KEY", "") DB_PATH = os.environ.get("DB_PATH", "/data/cache.db") REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "20")) @@ -46,6 +48,50 @@ def api_get(base, key, endpoint): r.raise_for_status() return r.json() +def sab_get(mode, **params): + if not SAB_URL or not SAB_API_KEY: + raise RuntimeError("SAB_URL oder SAB_API_KEY ist nicht gesetzt.") + query = {"output": "json", "apikey": SAB_API_KEY, "mode": mode, **params} + r = requests.get(f"{SAB_URL}/api", params=query, timeout=REQUEST_TIMEOUT) + r.raise_for_status() + data = r.json() + if data.get("error"): + raise RuntimeError(data["error"]) + return data + +def release_languages(name): + value = f" {name.lower().replace('.', ' ').replace('-', ' ')} " + found = [] + patterns = [("Deutsch", (" german ", " deutsch ", " german dubbed ", " ger dub ", " german dl ")), ("Englisch", (" english ", " eng ")), ("Japanisch", (" japanese ", " japan ", " jpn ")), ("Französisch", (" french ", " francais ", " fra ")), ("Spanisch", (" spanish ", " esp "))] + codes = {"Deutsch": "de", "Englisch": "en", "Japanisch": "ja", "Französisch": "fr", "Spanisch": "es"} + for language, tokens in patterns: + if any(token in value for token in tokens): found.append(norm_lang(codes[language])) + return unique_langs(found) + +def sab_downloads(): + queue = sab_get("queue", start=0, limit=100).get("queue") or {} + history = sab_get("history", start=0, limit=30, failed_only=0).get("history") or {} + wanted = [] + try: + if RADARR_API_KEY: wanted.extend({"title": x.get("movie", {}).get("title") or x.get("title", ""), "source": "Radarr"} for x in api_get(RADARR_URL, RADARR_API_KEY, "queue?includeUnknownMovieItems=true").get("records", [])) + if SONARR_URL and SONARR_API_KEY: wanted.extend({"title": x.get("title", ""), "source": "Sonarr"} for x in api_get(SONARR_URL, SONARR_API_KEY, "queue?includeUnknownSeriesItems=true").get("records", [])) + except Exception: + log.warning("Radarr/Sonarr-Queue konnte für SAB-Zuordnung nicht geladen werden", exc_info=True) + + def make_item(item, completed=False): + title = item.get("name") or item.get("filename") or item.get("nzb_name") or "—" + title_lower = title.lower() + source = "SABnzbd" + for candidate in wanted: + candidate_title = candidate["title"].lower() + if candidate_title and (candidate_title in title_lower or title_lower in candidate_title): source = candidate["source"]; break + percentage = 100.0 if completed else float(item.get("percentage") or 0) + return {"title": title, "status": item.get("status") or ("Completed" if completed else "Queued"), "percentage": percentage, "size": item.get("size") or "—", "remaining": item.get("sizeleft") or ("0 B" if completed else "—"), "timeleft": item.get("timeleft") or "—", "speed": item.get("speed") or "—", "category": item.get("cat") or item.get("category") or "—", "source": source, "languages": release_languages(title), "completed": completed} + + items = [make_item(item) for item in queue.get("slots", [])] + items.extend(make_item(item, completed=True) for item in history.get("slots", [])[:15]) + return {"items": items, "queue_status": queue.get("status", "Idle"), "speed": queue.get("speed", "0 B/s"), "paused": bool(queue.get("paused_all")), "error": None} + def norm_lang(code): code = (code or "").strip().lower() name = LANG_NAMES.get(code, code.upper() if code else "Unbekannt") @@ -173,7 +219,7 @@ def index(): 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) + return render_template("index.html", rows=rows, series=series, error=error, sonarr_enabled=bool(SONARR_URL and SONARR_API_KEY), sab_enabled=bool(SAB_URL and SAB_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(): @@ -189,7 +235,17 @@ def api_rescan(): def scan_status(): with job_lock: return jsonify(jobs) +@app.get("/api/downloads") +def downloads(): + if not SAB_URL or not SAB_API_KEY: + return jsonify({"items": [], "queue_status": "Nicht konfiguriert", "speed": "—", "paused": False, "error": "SAB_URL oder SAB_API_KEY ist nicht gesetzt."}) + try: + return jsonify(sab_downloads()) + except Exception as exc: + log.exception("SABnzbd konnte nicht abgefragt werden") + return jsonify({"items": [], "queue_status": "Fehler", "speed": "—", "paused": False, "error": str(exc)}), 502 + @app.get("/health") -def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY)}) +def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY), "sab_enabled":bool(SAB_URL and SAB_API_KEY)}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8099) diff --git a/docker-compose.yml b/docker-compose.yml index bd4aa3d..e84dad3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,10 @@ services: # SONARR_URL: "http://sonarr:8989" # SONARR_API_KEY: "CHANGE_ME" + # Optional: SABnzbd aktivieren + # SAB_URL: "http://sabnzbd:8080" + # SAB_API_KEY: "CHANGE_ME" + # Pfad, den Radarr in seiner API meldet: RADARR_MEDIA_PATH: "/data/filme" diff --git a/templates/index.html b/templates/index.html index e41d24a..7c40dc6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,7 +6,7 @@
Language Dashboard
Audio- und Untertitelspuren aus den Mediendateien
{% if error %}{% endif %} - +
Scan wird vorbereitet …Radarr: {{ radarr_media_path }}
@@ -16,6 +16,7 @@ {% if sonarr_enabled %}
{{ series|length }} SerienScan wird vorbereitet …Dashboard: {{ local_series_path }}
{% for group in series %}
{{ group.title }} ({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden{% for r in group.episodes %}{% endfor %}
EpisodeQualityStatusAudioUntertitelVideoGröße
{{ r.title }}{{ r.quality }}{{ r.state.label }}
{% for l in r.audio_languages %}{{ l.flag }} {{ l.name }}{% else %}{% endfor %}
{% for l in r.subtitle_languages %}{{ l.flag }} {{ l.name }}{% else %}{% endfor %}
{{ r.video_codec }}{{ r.size }}
{% endfor %}
{% endif %} +{% if sab_enabled %}
Downloads werden geladen …SABnzbd …Geschwindigkeit: —
DownloadStatusFortschrittGröße / RestRestzeitQuelleSprache
Downloads werden geladen …
{% endif %}
+ +