From eb47bcb8d12946cce3e8d3f4fd356aaebb98f648 Mon Sep 17 00:00:00 2001 From: Homelab Cursor Date: Sat, 28 Mar 2026 20:55:12 +0100 Subject: [PATCH] =?UTF-8?q?feat(savetv):=20Jellyfin-Filter,=20Archiv-Cache?= =?UTF-8?q?,=20Stabilit=C3=A4ts-Fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - savetv_web.py: Archiv-Cache mit Hintergrund-Refresh (kein Blockieren bei Save.TV-Login) - savetv_web.py: Flask threaded=True für parallele Requests - savetv_web.py: Jellyfin-Duplikate-Filter (Checkbox, default: ein) - tools/savetv.py: Login-Timeout (8s connect, 20s read) + modernerer User-Agent - savetv_sync.py: Dateien unter 700 MB werden übersprungen (kein SD-Schrott) - CT 116: www.save.tv statisch in /etc/hosts → kein DNS-GIL-Block mehr - CT 116: RAM von 512 MB auf 1 GB erhöht (war der Hauptgrund für Einfrieren) --- homelab-ai-bot/savetv_sync.py | 118 +++++++++++++++++++++++++++++++++ homelab-ai-bot/savetv_web.py | 63 +++++++++++++++++- homelab-ai-bot/tools/savetv.py | 4 +- 3 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 homelab-ai-bot/savetv_sync.py diff --git a/homelab-ai-bot/savetv_sync.py b/homelab-ai-bot/savetv_sync.py new file mode 100644 index 00000000..903cfeb1 --- /dev/null +++ b/homelab-ai-bot/savetv_sync.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +SaveTV → NAS Sync +Wartet 12h ± 0-30min nach Download auf Hetzner bevor Datei auf NAS kommt. +""" +import os, json, time, random, urllib.request, urllib.parse, urllib.error, email.utils +from datetime import datetime + +ZIEL = "/mnt/nas/Filme zum nachbearbeiten" +BASE = "http://138.201.84.95:9443/files" +API = "http://138.201.84.95:9443/api/films" +CALLBACK = "http://138.201.84.95:9443/api/nas_synced" +LOG = "/var/log/savetv_sync.log" + +MIN_ALTER_H = 24 +JITTER_MIN = 30 # ± bis zu 30 Minuten Zufall + +def log(msg): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}" + print(line, flush=True) + with open(LOG, "a") as f: + f.write(line + "\n") + +def get_filmliste(): + try: + with urllib.request.urlopen(API, timeout=15) as r: + d = json.loads(r.read()) + films = d.get("downloads", []) + return [f if isinstance(f, str) else f.get("name", "") for f in films if f] + except Exception as e: + log(f"API-Fehler: {e}") + return [] + +def get_file_age_hours(name): + """Alter der Datei auf Hetzner in Stunden via Last-Modified Header.""" + url = BASE + "/" + urllib.parse.quote(name) + try: + req = urllib.request.Request(url, method="HEAD") + with urllib.request.urlopen(req, timeout=10) as r: + lm = r.headers.get("Last-Modified") + if lm: + ts = email.utils.parsedate_to_datetime(lm).timestamp() + return (time.time() - ts) / 3600 + except Exception: + pass + return None + +def sync(): + filme = get_filmliste() + if not filme: + log("Keine Filme in API") + return + + log(f"{len(filme)} Filme in API") + kopiert = 0 + + for name in filme: + if not name: + continue + + ziel = os.path.join(ZIEL, name) + + # Schon vorhanden und vollständig? + if os.path.exists(ziel) and os.path.getsize(ziel) > 700 * 1024 * 1024: + continue + + # Dateigröße prüfen (HEAD) + url = BASE + "/" + urllib.parse.quote(name) + try: + req = urllib.request.Request(url, method="HEAD") + with urllib.request.urlopen(req, timeout=10) as r: + cl = int(r.headers.get("Content-Length", 0)) + size_mb = cl / 1024 / 1024 + except Exception: + size_mb = 0 + if size_mb < 700: + log(f"SKIP (zu klein, {size_mb:.0f} MB): {name}") + continue + + # Alter prüfen + alter_h = get_file_age_hours(name) + if alter_h is None: + log(f"SKIP (kein Header): {name}") + continue + + # Wartezeit: 12h + zufällige 0-30min + warte_h = MIN_ALTER_H + random.uniform(0, JITTER_MIN / 60) + + if alter_h < warte_h: + rest_min = (warte_h - alter_h) * 60 + log(f"WARTE noch {rest_min:.0f} min: {name} (Alter: {alter_h:.1f}h)") + continue + + # Kopieren + url = BASE + "/" + urllib.parse.quote(name) + log(f"LADE ({alter_h:.1f}h alt): {name}") + try: + urllib.request.urlretrieve(url, ziel) + size_mb = os.path.getsize(ziel) / 1024 / 1024 + log(f" OK ({size_mb:.0f} MB): {name}") + kopiert += 1 + try: + body = json.dumps({"name": name}).encode() + req = urllib.request.Request(CALLBACK, data=body, + headers={"Content-Type": "application/json"}, method="POST") + urllib.request.urlopen(req, timeout=5) + except Exception: + pass + except Exception as e: + log(f" FEHLER: {e}: {name}") + if os.path.exists(ziel): + os.remove(ziel) + + log(f"Fertig. {kopiert} neue Filme kopiert.") + +if __name__ == "__main__": + sync() diff --git a/homelab-ai-bot/savetv_web.py b/homelab-ai-bot/savetv_web.py index 9c2998e6..ff57e9f5 100644 --- a/homelab-ai-bot/savetv_web.py +++ b/homelab-ai-bot/savetv_web.py @@ -24,6 +24,55 @@ app = Flask(__name__) DOWNLOAD_LOG = Path("/mnt/savetv/.download_log.json") +# ── Archiv-Cache (Hintergrund-Refresh) ────────────────────────────────────── +_ARCHIVE_CACHE_FILE = Path("/mnt/savetv/.archive_cache.json") +_ARCHIVE_CACHE_TTL = 1800 # 30 min +_archive_lock = threading.Lock() +_archive_refreshing = False + +def _load_archive_cache(): + """Aus Datei-Cache lesen (sofort, kein Netzwerk).""" + try: + if _ARCHIVE_CACHE_FILE.exists(): + data = json.loads(_ARCHIVE_CACHE_FILE.read_text()) + return data.get("entries", []), float(data.get("ts", 0)) + except Exception: + pass + return [], 0.0 + +def _save_archive_cache(entries): + try: + _ARCHIVE_CACHE_FILE.write_text( + json.dumps({"ts": datetime.now().timestamp(), "entries": entries}, + ensure_ascii=False)) + except Exception: + pass + +def _refresh_archive_bg(): + """Save.TV-Archiv im Hintergrund aktualisieren.""" + global _archive_refreshing + with _archive_lock: + if _archive_refreshing: + return + _archive_refreshing = True + try: + entries = savetv._get_full_archive() + if entries: + _save_archive_cache(entries) + except Exception: + pass + finally: + _archive_refreshing = False + +def _get_archive_cached(): + """Sofort aus Cache zurückgeben, Hintergrund-Refresh anstoßen wenn nötig.""" + entries, ts = _load_archive_cache() + age = datetime.now().timestamp() - ts + if age > _ARCHIVE_CACHE_TTL or not entries: + t = threading.Thread(target=_refresh_archive_bg, daemon=True) + t.start() + return entries + def _load_download_log(): if DOWNLOAD_LOG.exists(): @@ -396,6 +445,10 @@ HTML = r""" +