feat(savetv): Pipeline-Dashboard mit 3 Blöcken (Warten/Bereit/NAS)

- /downloads ersetzt durch Pipeline-Dashboard
- /api/pipeline: Status aller Filme (pending/bereit/auf NAS)
- /api/nas_synced: Callback wenn Jellyfin-Sync fertig
- Sync-Script meldet sich nach erfolg zurück ans CT
This commit is contained in:
Homelab Cursor 2026-03-28 19:39:53 +01:00
parent 98260bfd7d
commit 8c1e810204

View file

@ -165,98 +165,226 @@ def register_extra_routes(app, progress_lock=None, load_progress_raw=None, save_
500,
)
NAS_DONE_FILE = SAVETV_DIR / ".nas_done.json"
NAS_SYNC_MIN_H = 12.0
NAS_SYNC_JITTER_H = 0.5 # 0-30 min
def _load_nas_done():
try:
if NAS_DONE_FILE.exists():
return _json.loads(NAS_DONE_FILE.read_text())
except Exception:
pass
return {}
def _save_nas_done(d):
try:
NAS_DONE_FILE.write_text(_json.dumps(d, ensure_ascii=False, indent=2))
except Exception:
pass
@app.route("/api/nas_synced", methods=["POST"])
def api_nas_synced():
data = request.get_json(silent=True) or {}
name = data.get("name", "").strip()
if not name:
return jsonify({"ok": False}), 400
done = _load_nas_done()
from datetime import datetime as _dt
done[name] = _dt.now().strftime("%Y-%m-%dT%H:%M:%S")
_save_nas_done(done)
return jsonify({"ok": True})
@app.route("/api/pipeline")
def api_pipeline():
import math as _math
now = _time.time()
done = _load_nas_done()
hetzner, pending, transferred = [], [], []
try:
for fp in SAVETV_DIR.iterdir():
if fp.suffix != ".mp4":
continue
st = fp.stat()
if st.st_size < 1_000_000:
continue
age_h = (now - st.st_mtime) / 3600
size_mb = round(st.st_size / 1024 / 1024, 1)
name = fp.name
clean = name.rsplit(".", 1)[0]
if name in done:
continue
eta_h = max(0, NAS_SYNC_MIN_H - age_h)
entry = {"name": name, "clean": clean,
"size_mb": size_mb, "age_h": round(age_h, 2),
"mtime": int(st.st_mtime)}
if eta_h > 0:
entry["eta_h"] = round(eta_h, 2)
pending.append(entry)
else:
hetzner.append(entry)
except Exception as e:
pass
hetzner.sort(key=lambda x: x["mtime"], reverse=True)
pending.sort(key=lambda x: x.get("eta_h", 0))
from datetime import datetime as _dt
for name, ts in sorted(done.items(), key=lambda x: x[1], reverse=True)[:20]:
clean = name.rsplit(".", 1)[0]
transferred.append({"name": name, "clean": clean, "synced_at": ts})
return jsonify({"hetzner": hetzner, "pending": pending, "transferred": transferred,
"total_gb": round(sum(f["size_mb"] for f in hetzner + pending) / 1024, 2)})
@app.route("/downloads")
def downloads_page():
files = []
for fp in SAVETV_DIR.iterdir():
if fp.suffix == ".mp4":
st = fp.stat()
size_mb = round(st.st_size / 1024 / 1024, 1)
mtime = st.st_mtime
files.append((fp.name, size_mb, mtime))
files.sort(key=lambda x: x[2], reverse=True)
total_gb = round(sum(s for _, s, _ in files) / 1024, 2)
nav = '<div style="display:flex;gap:16px;margin-bottom:24px"><a href="/" style="color:#999;text-decoration:none;font-size:15px">&larr; Archiv</a><a href="/downloads" style="color:#4caf92;text-decoration:none;font-size:15px">&#128193; Downloads</a><a href="/status" style="color:#5a7fa8;text-decoration:none;font-size:15px">&#9881;&#65039; Status</a></div>'
rows = ""
from datetime import datetime as _dt
for name, size, mtime in files:
clean = name.rsplit(".", 1)[0]
esc = _html.escape(name, quote=True)
date_str = _dt.fromtimestamp(mtime).strftime("%d.%m.%Y")
href_direct = SAVETV_DIRECT_BASE.rstrip("/") + "/files/" + _urlquote(name)
href_tunnel = SAVETV_TUNNEL_BASE.rstrip("/") + "/files/" + _urlquote(name)
rows += (
'<tr data-name="' + _html.escape(clean.lower(), quote=True) + '" '
'data-date="' + str(int(mtime)) + '" '
'data-size="' + str(size) + '">'
'<td style="padding:16px 20px;font-size:18px;font-weight:500">' + clean + '</td>'
'<td style="padding:16px 20px;color:#666;font-size:14px;white-space:nowrap">' + date_str + '</td>'
'<td style="padding:16px 20px;color:#888;font-size:15px;white-space:nowrap">' + str(size) + ' MB</td>'
'<td style="padding:16px 20px;white-space:nowrap">'
'<a href="' + href_direct + '" download="' + esc + '" '
'style="background:#4caf92;color:#000;font-weight:700;padding:8px 20px;border-radius:5px;text-decoration:none;font-size:15px">'
'&#11015; Download</a> '
'<a href="' + href_tunnel + '" title="Cloudflare-Tunnel" '
'style="color:#5a7fa8;font-size:13px;font-weight:600;margin-left:8px;text-decoration:none">CF</a> '
'<button data-file="' + esc + '" onclick="delFile(this)" '
'style="background:#ff3d3d;color:#fff;font-weight:700;padding:8px 16px;border-radius:5px;border:none;cursor:pointer;font-size:15px;margin-left:10px">'
'&#128465; L\u00f6schen</button>'
'</td></tr>'
)
return (
'<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Downloads</title>'
'<style>'
'*{box-sizing:border-box}'
'body{background:#111118;color:#e8e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0;padding:32px 40px;font-size:16px;line-height:1.5}'
'h1{font-size:28px;margin-bottom:8px;font-weight:700;letter-spacing:-0.5px}'
'.sub{color:#777;font-size:15px;margin-bottom:16px}'
'.sortbar{display:flex;align-items:center;gap:8px;margin-bottom:24px;flex-wrap:wrap}'
'.sortbar span{color:#666;font-size:14px;margin-right:4px}'
'.sbtn{background:#1e1e2e;color:#999;border:1px solid #2a2a3a;padding:7px 16px;border-radius:6px;cursor:pointer;font-size:14px;font-weight:600;transition:all .15s}'
'.sbtn:hover{background:#252538;color:#ccc;border-color:#3a3a5a}'
'.sbtn.active{background:#1a2a3a;color:#4caf92;border-color:#4caf92}'
'.sbtn .arrow{margin-left:5px;font-size:11px}'
'table{border-collapse:collapse;width:100%;max-width:1100px}'
'tr:hover{background:#1a1a24}'
'tr{border-bottom:1px solid #2a2a3a}'
'</style></head><body>'
+ nav +
'<h1>&#128193; Gespeicherte Filme</h1>'
'<div class="sub">' + str(len(files)) + ' Dateien &middot; ' + str(total_gb) + ' GB</div>'
'<div class="sub" style="margin-top:16px;padding:12px 16px;background:#1a1a24;border:1px solid #2a2a3a;border-radius:8px;font-size:14px;max-width:1100px;line-height:1.55">'
'Direkt vom Hetzner (ohne Cloudflare). '
'Credentials sind im Link eingebettet &ndash; Browser-Auth-Dialog sollte nicht erscheinen. '
'Fallback-Link <span style="color:#888">CF</span> pro Zeile nutzt den Cloudflare-Tunnel '
'(<code style="color:#e0e0e8">' + _html.escape(SAVETV_TUNNEL_BASE, quote=True) + '</code>).'
'</div>'
'<div class="sortbar"><span>Sortieren:</span>'
'<button class="sbtn active" data-sort="date" data-dir="desc" onclick="sortBy(this)">Datum <span class="arrow">&#9660;</span></button>'
'<button class="sbtn" data-sort="name" data-dir="asc" onclick="sortBy(this)">Name <span class="arrow">&#9650;</span></button>'
'<button class="sbtn" data-sort="size" data-dir="desc" onclick="sortBy(this)">Gr&ouml;&szlig;e <span class="arrow">&#9660;</span></button>'
'</div>'
'<table id="ftable"><tbody id="fbody">' + rows + '</tbody></table>'
'<script>'
'function sortBy(btn){'
' var key=btn.getAttribute("data-sort");'
' var dir=btn.getAttribute("data-dir");'
' var prev=document.querySelector(".sbtn.active");'
' if(prev&&prev===btn){dir=dir==="asc"?"desc":"asc";btn.setAttribute("data-dir",dir)}'
' else{if(prev)prev.classList.remove("active");btn.classList.add("active")}'
' btn.querySelector(".arrow").innerHTML=dir==="asc"?"&#9650;":"&#9660;";'
' var tb=document.getElementById("fbody");'
' var rows=Array.from(tb.querySelectorAll("tr"));'
' rows.sort(function(a,b){'
' var av,bv;'
' if(key==="date"){av=parseInt(a.getAttribute("data-date"));bv=parseInt(b.getAttribute("data-date"))}'
' else if(key==="size"){av=parseFloat(a.getAttribute("data-size"));bv=parseFloat(b.getAttribute("data-size"))}'
' else{av=a.getAttribute("data-name");bv=b.getAttribute("data-name");return dir==="asc"?av.localeCompare(bv):bv.localeCompare(av)}'
' return dir==="asc"?av-bv:bv-av'
' });'
' rows.forEach(function(r){tb.appendChild(r)})'
'}'
'function delFile(btn){var n=btn.getAttribute("data-file");if(!confirm("Wirklich loeschen? "+n))return;fetch("/api/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:n})}).then(function(r){return r.json()}).then(function(d){if(d.ok){btn.closest("tr").remove()}else{alert(d.error)}})}'
'</script></body></html>'
)
return '''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SaveTV Pipeline</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0e0e16;color:#e0e0ec;font-family:system-ui,-apple-system,sans-serif;font-size:15px;line-height:1.5;padding:24px 20px}
nav{display:flex;gap:16px;margin-bottom:28px;flex-wrap:wrap}
nav a{color:#666;text-decoration:none;font-size:14px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3a;transition:.15s}
nav a:hover{color:#ccc;border-color:#444}
nav a.active{color:#4caf92;border-color:#4caf92}
h1{font-size:24px;font-weight:700;margin-bottom:4px}
.meta{color:#555;font-size:13px;margin-bottom:28px}
.block{margin-bottom:28px;border-radius:10px;overflow:hidden;border:1px solid #1e1e2e}
.block-header{display:flex;align-items:center;gap:10px;padding:14px 18px;font-weight:700;font-size:15px}
.block-header .count{margin-left:auto;font-size:13px;font-weight:400;color:#666}
.bh-green{background:#0d1f14;border-bottom:1px solid #1e2e20}
.bh-orange{background:#1f1a0d;border-bottom:1px solid #2e260e}
.bh-blue{background:#0d1220;border-bottom:1px solid #1a2030}
.film{display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid #16161e;transition:.12s}
.film:last-child{border-bottom:none}
.film:hover{background:#131320}
.film-name{flex:1;font-size:15px;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.film-meta{font-size:12px;color:#555;white-space:nowrap;flex-shrink:0}
.badge{font-size:11px;font-weight:700;padding:3px 8px;border-radius:4px;white-space:nowrap;flex-shrink:0}
.badge-green{background:#0d2a18;color:#4caf92;border:1px solid #2a5a3a}
.badge-orange{background:#2a1e08;color:#ffa726;border:1px solid #5a3a10}
.badge-blue{background:#0a1830;color:#5a9af8;border:1px solid #1a3060}
.empty{padding:20px 18px;color:#444;font-style:italic;font-size:14px}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid #333;border-top-color:#4caf92;border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
.del-btn{background:none;border:none;color:#444;cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px;transition:.12s;flex-shrink:0}
.del-btn:hover{color:#ff5555;background:#2a1010}
.refresh{color:#444;font-size:12px;margin-left:auto}
</style>
</head>
<body>
<nav>
<a href="/">&larr; Archiv</a>
<a href="/downloads" class="active">&#9654; Pipeline</a>
<a href="/status">&#9881; Status</a>
</nav>
<h1>&#9654; SaveTV Pipeline</h1>
<div class="meta" id="meta">Lade...</div>
<div class="block">
<div class="block-header bh-orange">
<span>&#9200; Wartet auf NAS-Transfer</span>
<span class="count" id="cnt-pending"></span>
</div>
<div id="list-pending"><div class="empty">Lade...</div></div>
</div>
<div class="block">
<div class="block-header bh-green">
<span>&#128190; Auf Hetzner bereit</span>
<span class="count" id="cnt-hetzner"></span>
</div>
<div id="list-hetzner"><div class="empty">Lade...</div></div>
</div>
<div class="block">
<div class="block-header bh-blue">
<span>&#10003; Auf deiner NAS</span>
<span class="count" id="cnt-done"></span>
</div>
<div id="list-done"><div class="empty">Lade...</div></div>
</div>
<script>
function fmtEta(h){
if(h<=0)return'bereit';
var t=Math.round(h*60);
if(t<60)return'noch '+t+' min';
return'noch '+(h).toFixed(1).replace('.',',')+' h';
}
function fmtSize(mb){
if(mb>=1024)return(mb/1024).toFixed(1)+' GB';
return mb+' MB';
}
function fmtTs(ts){
if(!ts)return'';
var d=new Date(ts);
return d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit'})+' '+
d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
}
function delFile(name,btn){
if(!confirm('Wirklich löschen?\\n'+name))return;
fetch('/api/delete',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({filename:name})})
.then(r=>r.json()).then(d=>{
if(d.ok)btn.closest('.film').remove();
else alert(d.error);
});
}
function load(){
fetch('/api/pipeline').then(r=>r.json()).then(d=>{
var total=d.hetzner.length+d.pending.length;
document.getElementById('meta').textContent=
total+' Filme auf Hetzner \u00b7 '+d.total_gb+' GB \u00b7 '+
'aktualisiert '+new Date().toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
// Pending
document.getElementById('cnt-pending').textContent=d.pending.length+' Film'+(d.pending.length!==1?'e':'');
var hp=document.getElementById('list-pending');
if(!d.pending.length){hp.innerHTML='<div class="empty">Keine Filme warten auf Transfer</div>';}
else{hp.innerHTML=d.pending.map(f=>
'<div class="film">'+
'<div class="film-name">'+f.clean+'</div>'+
'<div class="film-meta">'+fmtSize(f.size_mb)+'</div>'+
'<span class="badge badge-orange">'+fmtEta(f.eta_h)+'</span>'+
'</div>'
).join('');}
// Hetzner bereit
document.getElementById('cnt-hetzner').textContent=d.hetzner.length+' Film'+(d.hetzner.length!==1?'e':'');
var hh=document.getElementById('list-hetzner');
if(!d.hetzner.length){hh.innerHTML='<div class="empty">Keine Filme bereit (Transfer l\u00e4uft gleich)</div>';}
else{hh.innerHTML=d.hetzner.map(f=>
'<div class="film">'+
'<div class="film-name">'+f.clean+'</div>'+
'<div class="film-meta">'+fmtSize(f.size_mb)+' \u00b7 '+f.age_h.toFixed(1)+' h alt</div>'+
'<span class="badge badge-green">Bereit</span>'+
'<button class="del-btn" title="L\\u00f6schen" onclick="delFile('+JSON.stringify(f.name)+',this)">&#128465;</button>'+
'</div>'
).join('');}
// NAS done
document.getElementById('cnt-done').textContent=d.transferred.length+' Film'+(d.transferred.length!==1?'e':'');
var hd=document.getElementById('list-done');
if(!d.transferred.length){hd.innerHTML='<div class="empty">Noch keine Filme auf NAS angekommen</div>';}
else{hd.innerHTML=d.transferred.map(f=>
'<div class="film">'+
'<div class="film-name">'+f.clean+'</div>'+
'<div class="film-meta">'+fmtTs(f.synced_at)+'</div>'+
'<span class="badge badge-blue">&#10003; NAS</span>'+
'</div>'
).join('');}
}).catch(e=>{
document.getElementById('meta').textContent='Fehler beim Laden: '+e;
});
}
load();
setInterval(load,30000);
</script>
</body>
</html>'''
FILMINFO_CACHE = Path("/mnt/savetv/.filminfo_cache.json")