diff --git a/README.md b/README.md index c65fe44..4d52f22 100644 --- a/README.md +++ b/README.md @@ -83,15 +83,17 @@ Die Container müssen im gemeinsamen Docker-Netzwerk `media-stack_default` errei ## Scans und Performance -Scans laufen nach dem Laden der Bibliotheksdaten im Hintergrund. Über den Button „Neu scannen“ kann zwischen Radarr, Sonarr, allen Quellen und ausschließlich den aktuellen Einträgen der Liste „Ohne Deutsch“ gewählt werden. Die automatische Scan-Häufigkeit wird in den Einstellungen festgelegt. +Der Bibliotheks-Sync läuft als serverseitiger Hintergrund-Thread und benötigt keinen geöffneten Browser. Beim Start und anschließend nach `SYNC_INTERVAL_SECONDS` werden Radarr, Sonarr und die Sprachprüfung aktualisiert. Über den Button „Neu scannen“ kann weiterhin manuell zwischen Radarr, Sonarr, allen Quellen und ausschließlich den aktuellen Einträgen der Liste „Ohne Deutsch“ gewählt werden. ```env SCAN_WORKERS=4 REQUEST_TIMEOUT=20 DB_PATH=/data/cache.db +SYNC_INTERVAL_SECONDS=1800 ``` `SCAN_WORKERS` steuert die Anzahl paralleler `ffprobe`-Jobs. Höhere Werte beschleunigen große Bibliotheken, erhöhen aber die I/O-Last. +`SYNC_INTERVAL_SECONDS` steuert den serverseitigen Sync-Takt; `1800` entspricht 30 Minuten, `18000` fünf Stunden und `0` deaktiviert die periodische Wiederholung (der Start-Sync bleibt aktiv). ## Datastore, RAID und SMART diff --git a/app.py b/app.py index 84663d0..a6c9154 100644 --- a/app.py +++ b/app.py @@ -48,6 +48,7 @@ STORAGE_CACHE_SECONDS = int(os.environ.get("STORAGE_CACHE_SECONDS", "900")) SMART_ENABLED = os.environ.get("SMART_ENABLED", "false").lower() in {"1", "true", "yes", "on"} LOG_FILE = os.environ.get("LOG_FILE", "/data/media-max.log") LOG_RETENTION_HOURS = max(1, int(os.environ.get("LOG_RETENTION_HOURS", "6"))) +SYNC_INTERVAL_SECONDS = max(0, int(os.environ.get("SYNC_INTERVAL_SECONDS", "1800"))) logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("media-max") @@ -90,6 +91,8 @@ raid_log_state = None graphics_log_state = None graphics_process_log_state = None hardware_log_once = set() +library_cache_lock = threading.Lock() +library_cache = {"rows": [], "series": [], "updated_at": 0.0, "error": None, "ready": False} 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":"❓"} @@ -960,7 +963,7 @@ def sonarr_groups(): "category":category_key, "category_label":category_label}) return sorted(groups, key=lambda x: x["title"].lower()) -def start_scan(source, force=False): +def start_scan(source, force=False, rows_snapshot=None, series_snapshot=None): 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} @@ -968,16 +971,16 @@ def start_scan(source, force=False): try: paths = [] if source == "radarr": - for row in radarr_rows(): + for row in rows_snapshot if rows_snapshot is not None else radarr_rows(): if row["local_path"] != "—" and row["pending"]: paths.append(row["local_path"]) elif source == "sonarr": - for group in sonarr_groups(): + for group in series_snapshot if series_snapshot is not None else sonarr_groups(): paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "—" and r["pending"]) else: - for row in radarr_rows(): + for row in rows_snapshot if rows_snapshot is not None else radarr_rows(): if row["local_path"] != "—" and is_german_review_candidate(row) and not has_german_track(row): paths.append(row["local_path"]) if SONARR_URL and SONARR_API_KEY: - for group in sonarr_groups(): + for group in series_snapshot if series_snapshot is not None else sonarr_groups(): paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "—" and is_german_review_candidate(r) and not has_german_track(r)) with job_lock: jobs[source]["total"] = len(paths) futures = [scan_executor.submit(scan_one, path) for path in paths] @@ -989,18 +992,59 @@ def start_scan(source, force=False): log.error("%s-Scan fehlgeschlagen: %s", source, error_summary(exc)); jobs[source]["error"] = str(exc); jobs[source]["state"] = "error" executor.submit(work) -@app.route("/") -def index(): - error = None; rows = []; series = [] +def refresh_library_cache(): + """Refresh Radarr/Sonarr data outside the request thread.""" + rows = [] + series = [] + errors = [] + radarr_ok = False + sonarr_ok = not (SONARR_URL and SONARR_API_KEY) try: rows = radarr_rows() - if any(row["pending"] for row in rows): start_scan("radarr") - except Exception as exc: error = str(exc) + radarr_ok = True + except Exception as exc: + errors.append(f"Radarr nicht erreichbar: {error_summary(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.error("Sonarr nicht erreichbar: %s", error_summary(exc)); error = f"{error + ' | ' if error else ''}Sonarr nicht erreichbar" + sonarr_ok = True + except Exception as exc: + errors.append(f"Sonarr nicht erreichbar: {error_summary(exc)}") + with library_cache_lock: + # Keep the last good snapshot during a short service/DNS outage. + if not radarr_ok: + rows = library_cache["rows"] + if not sonarr_ok: + series = library_cache["series"] + library_cache.update({"rows": rows, "series": series, "updated_at": time.time(), "error": " | ".join(errors) or None, "ready": True}) + if rows and any(row.get("pending") for row in rows): + start_scan("radarr", force=True, rows_snapshot=rows) + if series and any(row.get("pending") for group in series for row in group["episodes"]): + start_scan("sonarr", force=True, series_snapshot=series) + if rows or series: + start_scan("missing", force=True, rows_snapshot=rows, series_snapshot=series) + log.info("Bibliotheks-Sync abgeschlossen: %s Filme, %s Serien", len(rows), len(series)) + +def background_library_sync(): + """Keep library data and media metadata current without a browser tab.""" + log.info("Hintergrund-Sync gestartet (Intervall: %ss)", SYNC_INTERVAL_SECONDS or "deaktiviert") + while True: + try: + refresh_library_cache() + except Exception as exc: + log.error("Hintergrund-Sync fehlgeschlagen: %s", error_summary(exc)) + if not SYNC_INTERVAL_SECONDS: + return + time.sleep(SYNC_INTERVAL_SECONDS) + +threading.Thread(target=background_library_sync, name="library-sync", daemon=True).start() + +@app.route("/") +def index(): + with library_cache_lock: + rows = list(library_cache["rows"]) + series = list(library_cache["series"]) + error = library_cache["error"] missing_german_movies = [row for row in rows if is_german_review_candidate(row) and not has_german_track(row)] missing_german_series = [] for group in series: diff --git a/docker-compose.yml b/docker-compose.yml index ce6150f..35a7993 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: environment: NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all} NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility} + # Serverseitiger Bibliotheks-Sync; 1800 = alle 30 Minuten, 0 = nur beim Start. + SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800} volumes: # Host-Pfad deiner Film-Library -> interner Dashboard-Pfad - ${FILM_MEDIA_VOLUME}:/media/filme:ro diff --git a/templates/index.html b/templates/index.html index 2d63ac8..7f09278 100644 --- a/templates/index.html +++ b/templates/index.html @@ -101,7 +101,7 @@ document.querySelector('#rescan').onclick=async()=>{const b=document.querySelect