homelab-brain/homelab-ai-bot/savetv_sync.py

143 lines
4.3 KiB
Python

#!/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()