#!/usr/bin/env python3 """ SaveTV → NAS Sync 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 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" 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 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(): """Echte MP4-Dateinamen die noch nicht ans NAS übertragen wurden.""" try: with urllib.request.urlopen(FILELIST_API, timeout=20) as r: d = json.loads(r.read()) files = d.get("files", []) log(f"filelist: {len(files)} Filme warten auf NAS") return files except Exception as 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=15) 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 filelist") return kopiert = 0 for name in filme: if not name or not name.endswith(".mp4"): continue ziel = os.path.join(ZIEL, name) if os.path.exists(ziel) and os.path.getsize(ziel) > MIN_SIZE_MB * 1024 * 1024: log(f"SKIP (schon auf NAS): {name}") continue url = BASE + "/" + urllib.parse.quote(name) try: req = urllib.request.Request(url, method="HEAD") with urllib.request.urlopen(req, timeout=15) as r: cl = int(r.headers.get("Content-Length", 0)) size_mb = cl / 1024 / 1024 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_h = get_file_age_hours(name) if alter_h is None: log(f"SKIP (kein Last-Modified): {name}") continue 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 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 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=10) except Exception as e: log(f" CALLBACK warn: {e}") except Exception as e: log(f" FEHLER: {e}: {name}") if os.path.exists(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: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: log("Sync läuft bereits, überspringe diesen Start.") raise SystemExit(0) sync()