fix(savetv): NAS-Sync nutzt /api/filelist statt veraltete downloads-IDs

This commit is contained in:
Homelab Cursor 2026-05-21 14:52:49 +02:00
parent 9e4cb33b08
commit 8f491ff36e

View file

@ -1,20 +1,31 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
SaveTV NAS Sync 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 from datetime import datetime
ZIEL = "/mnt/nas/Filme zum nachbearbeiten" ZIEL = "/mnt/nas/Filme zum nachbearbeiten"
BASE = "http://138.201.84.95:9443/files" BASE = "http://138.201.84.95:9443/files"
API = "http://138.201.84.95:9443/api/films" FILELIST_API = "http://138.201.84.95:9443/api/filelist"
CALLBACK = "http://138.201.84.95:9443/api/nas_synced" CALLBACK = "http://138.201.84.95:9443/api/nas_synced"
LOG = "/var/log/savetv_sync.log" LOG = "/var/log/savetv_sync.log"
LOCKFILE = "/tmp/savetv_sync.lock" LOCKFILE = "/tmp/savetv_sync.lock"
MIN_ALTER_H = 24 MIN_ALTER_H = 24
JITTER_MIN = 30 # ± bis zu 30 Minuten Zufall JITTER_MIN = 30 # ± bis zu 30 Minuten Zufall
MIN_SIZE_MB = 700
def log(msg): def log(msg):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@ -23,22 +34,26 @@ def log(msg):
with open(LOG, "a") as f: with open(LOG, "a") as f:
f.write(line + "\n") f.write(line + "\n")
def get_filmliste(): def get_filmliste():
"""Echte MP4-Dateinamen die noch nicht ans NAS übertragen wurden."""
try: 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()) d = json.loads(r.read())
films = d.get("downloads", []) files = d.get("files", [])
return [f if isinstance(f, str) else f.get("name", "") for f in films if f] log(f"filelist: {len(files)} Filme warten auf NAS")
return files
except Exception as e: except Exception as e:
log(f"API-Fehler: {e}") log(f"filelist API-Fehler: {e}")
return [] return []
def get_file_age_hours(name): def get_file_age_hours(name):
"""Alter der Datei auf Hetzner in Stunden via Last-Modified Header.""" """Alter der Datei auf Hetzner in Stunden via Last-Modified Header."""
url = BASE + "/" + urllib.parse.quote(name) url = BASE + "/" + urllib.parse.quote(name)
try: try:
req = urllib.request.Request(url, method="HEAD") 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") lm = r.headers.get("Last-Modified")
if lm: if lm:
ts = email.utils.parsedate_to_datetime(lm).timestamp() ts = email.utils.parsedate_to_datetime(lm).timestamp()
@ -47,55 +62,50 @@ def get_file_age_hours(name):
pass pass
return None return None
def sync(): def sync():
filme = get_filmliste() filme = get_filmliste()
if not filme: if not filme:
log("Keine Filme in API") log("Keine Filme in filelist")
return return
log(f"{len(filme)} Filme in API")
kopiert = 0 kopiert = 0
for name in filme: for name in filme:
if not name: if not name or not name.endswith(".mp4"):
continue continue
ziel = os.path.join(ZIEL, name) ziel = os.path.join(ZIEL, name)
# Schon vorhanden und vollständig? if os.path.exists(ziel) and os.path.getsize(ziel) > MIN_SIZE_MB * 1024 * 1024:
if os.path.exists(ziel) and os.path.getsize(ziel) > 700 * 1024 * 1024: log(f"SKIP (schon auf NAS): {name}")
continue continue
# Dateigröße prüfen (HEAD)
url = BASE + "/" + urllib.parse.quote(name) url = BASE + "/" + urllib.parse.quote(name)
try: try:
req = urllib.request.Request(url, method="HEAD") 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)) cl = int(r.headers.get("Content-Length", 0))
size_mb = cl / 1024 / 1024 size_mb = cl / 1024 / 1024
except Exception: except Exception as e:
size_mb = 0 log(f"SKIP (HEAD fehlgeschlagen): {name}{e}")
if size_mb < 700: continue
if size_mb < MIN_SIZE_MB:
log(f"SKIP (zu klein, {size_mb:.0f} MB): {name}") log(f"SKIP (zu klein, {size_mb:.0f} MB): {name}")
continue continue
# Alter prüfen
alter_h = get_file_age_hours(name) alter_h = get_file_age_hours(name)
if alter_h is None: if alter_h is None:
log(f"SKIP (kein Header): {name}") log(f"SKIP (kein Last-Modified): {name}")
continue continue
# Wartezeit: 12h + zufällige 0-30min
warte_h = MIN_ALTER_H + random.uniform(0, JITTER_MIN / 60) warte_h = MIN_ALTER_H + random.uniform(0, JITTER_MIN / 60)
if alter_h < warte_h: if alter_h < warte_h:
rest_min = (warte_h - alter_h) * 60 rest_min = (warte_h - alter_h) * 60
log(f"WARTE noch {rest_min:.0f} min: {name} (Alter: {alter_h:.1f}h)") log(f"WARTE noch {rest_min:.0f} min: {name} (Alter: {alter_h:.1f}h)")
continue continue
# Kopieren log(f"LADE ({alter_h:.1f}h alt, {size_mb:.0f} MB): {name}")
url = BASE + "/" + urllib.parse.quote(name)
log(f"LADE ({alter_h:.1f}h alt): {name}")
try: try:
urllib.request.urlretrieve(url, ziel) urllib.request.urlretrieve(url, ziel)
size_mb = os.path.getsize(ziel) / 1024 / 1024 size_mb = os.path.getsize(ziel) / 1024 / 1024
@ -103,18 +113,26 @@ def sync():
kopiert += 1 kopiert += 1
try: try:
body = json.dumps({"name": name}).encode() body = json.dumps({"name": name}).encode()
req = urllib.request.Request(CALLBACK, data=body, req = urllib.request.Request(
headers={"Content-Type": "application/json"}, method="POST") CALLBACK,
urllib.request.urlopen(req, timeout=5) data=body,
except Exception: headers={"Content-Type": "application/json"},
pass method="POST",
)
urllib.request.urlopen(req, timeout=10)
except Exception as e:
log(f" CALLBACK warn: {e}")
except Exception as e: except Exception as e:
log(f" FEHLER: {e}: {name}") log(f" FEHLER: {e}: {name}")
if os.path.exists(ziel): if os.path.exists(ziel):
try:
os.remove(ziel) os.remove(ziel)
except OSError:
pass
log(f"Fertig. {kopiert} neue Filme kopiert.") log(f"Fertig. {kopiert} neue Filme kopiert.")
if __name__ == "__main__": if __name__ == "__main__":
with open(LOCKFILE, "w") as lock: with open(LOCKFILE, "w") as lock:
try: try: