From 8f491ff36e852bc7ede480e20e926a7d709f8eb6 Mon Sep 17 00:00:00 2001 From: Homelab Cursor Date: Thu, 21 May 2026 14:52:49 +0200 Subject: [PATCH] fix(savetv): NAS-Sync nutzt /api/filelist statt veraltete downloads-IDs --- homelab-ai-bot/savetv_sync.py | 96 +++++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 39 deletions(-) diff --git a/homelab-ai-bot/savetv_sync.py b/homelab-ai-bot/savetv_sync.py index 2fb369be1..c0200dce5 100644 --- a/homelab-ai-bot/savetv_sync.py +++ b/homelab-ai-bot/savetv_sync.py @@ -1,20 +1,31 @@ #!/usr/bin/env python3 """ SaveTV → NAS Sync -Wartet 12h ± 0-30min nach Download auf Hetzner bevor Datei auf NAS kommt. +Holt die echte MP4-Liste von /api/filelist (nicht /api/films downloads!). +Wartet 24h ± 0-30min nach Download auf Hetzner bevor Datei auf NAS kommt. """ -import os, json, time, random, fcntl, urllib.request, urllib.parse, urllib.error, email.utils +import json +import os +import random +import time +import urllib.error +import urllib.parse +import urllib.request +import email.utils +import fcntl 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" -LOCKFILE = "/tmp/savetv_sync.lock" +ZIEL = "/mnt/nas/Filme zum nachbearbeiten" +BASE = "http://138.201.84.95:9443/files" +FILELIST_API = "http://138.201.84.95:9443/api/filelist" +CALLBACK = "http://138.201.84.95:9443/api/nas_synced" +LOG = "/var/log/savetv_sync.log" +LOCKFILE = "/tmp/savetv_sync.lock" + +MIN_ALTER_H = 24 +JITTER_MIN = 30 # ± bis zu 30 Minuten Zufall +MIN_SIZE_MB = 700 -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") @@ -23,22 +34,26 @@ def log(msg): with open(LOG, "a") as f: f.write(line + "\n") + def get_filmliste(): + """Echte MP4-Dateinamen die noch nicht ans NAS übertragen wurden.""" try: - with urllib.request.urlopen(API, timeout=15) as r: + with urllib.request.urlopen(FILELIST_API, timeout=20) 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] + files = d.get("files", []) + log(f"filelist: {len(files)} Filme warten auf NAS") + return files except Exception as e: - log(f"API-Fehler: {e}") + log(f"filelist 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: + with urllib.request.urlopen(req, timeout=15) as r: lm = r.headers.get("Last-Modified") if lm: ts = email.utils.parsedate_to_datetime(lm).timestamp() @@ -47,55 +62,50 @@ def get_file_age_hours(name): pass return None + def sync(): filme = get_filmliste() if not filme: - log("Keine Filme in API") + log("Keine Filme in filelist") return - log(f"{len(filme)} Filme in API") kopiert = 0 - for name in filme: - if not name: + if not name or not name.endswith(".mp4"): 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: + if os.path.exists(ziel) and os.path.getsize(ziel) > MIN_SIZE_MB * 1024 * 1024: + log(f"SKIP (schon auf NAS): {name}") 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: + with urllib.request.urlopen(req, timeout=15) as r: cl = int(r.headers.get("Content-Length", 0)) size_mb = cl / 1024 / 1024 - except Exception: - size_mb = 0 - if size_mb < 700: + except Exception as e: + log(f"SKIP (HEAD fehlgeschlagen): {name} — {e}") + continue + + if size_mb < MIN_SIZE_MB: 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}") + log(f"SKIP (kein Last-Modified): {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}") + log(f"LADE ({alter_h:.1f}h alt, {size_mb:.0f} MB): {name}") try: urllib.request.urlretrieve(url, ziel) size_mb = os.path.getsize(ziel) / 1024 / 1024 @@ -103,18 +113,26 @@ def sync(): 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 + req = urllib.request.Request( + CALLBACK, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + urllib.request.urlopen(req, timeout=10) + except Exception as e: + log(f" CALLBACK warn: {e}") except Exception as e: log(f" FEHLER: {e}: {name}") if os.path.exists(ziel): - os.remove(ziel) + try: + os.remove(ziel) + except OSError: + pass log(f"Fertig. {kopiert} neue Filme kopiert.") + if __name__ == "__main__": with open(LOCKFILE, "w") as lock: try: