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:
parent
98260bfd7d
commit
8c1e810204
1 changed files with 218 additions and 90 deletions
|
|
@ -165,98 +165,226 @@ def register_extra_routes(app, progress_lock=None, load_progress_raw=None, save_
|
||||||
500,
|
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")
|
@app.route("/downloads")
|
||||||
def downloads_page():
|
def downloads_page():
|
||||||
files = []
|
return '''<!DOCTYPE html>
|
||||||
for fp in SAVETV_DIR.iterdir():
|
<html lang="de">
|
||||||
if fp.suffix == ".mp4":
|
<head>
|
||||||
st = fp.stat()
|
<meta charset="UTF-8">
|
||||||
size_mb = round(st.st_size / 1024 / 1024, 1)
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
mtime = st.st_mtime
|
<title>SaveTV Pipeline</title>
|
||||||
files.append((fp.name, size_mb, mtime))
|
<style>
|
||||||
files.sort(key=lambda x: x[2], reverse=True)
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
total_gb = round(sum(s for _, s, _ in files) / 1024, 2)
|
body{background:#0e0e16;color:#e0e0ec;font-family:system-ui,-apple-system,sans-serif;font-size:15px;line-height:1.5;padding:24px 20px}
|
||||||
nav = '<div style="display:flex;gap:16px;margin-bottom:24px"><a href="/" style="color:#999;text-decoration:none;font-size:15px">← Archiv</a><a href="/downloads" style="color:#4caf92;text-decoration:none;font-size:15px">📁 Downloads</a><a href="/status" style="color:#5a7fa8;text-decoration:none;font-size:15px">⚙️ Status</a></div>'
|
nav{display:flex;gap:16px;margin-bottom:28px;flex-wrap:wrap}
|
||||||
rows = ""
|
nav a{color:#666;text-decoration:none;font-size:14px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3a;transition:.15s}
|
||||||
from datetime import datetime as _dt
|
nav a:hover{color:#ccc;border-color:#444}
|
||||||
for name, size, mtime in files:
|
nav a.active{color:#4caf92;border-color:#4caf92}
|
||||||
clean = name.rsplit(".", 1)[0]
|
h1{font-size:24px;font-weight:700;margin-bottom:4px}
|
||||||
esc = _html.escape(name, quote=True)
|
.meta{color:#555;font-size:13px;margin-bottom:28px}
|
||||||
date_str = _dt.fromtimestamp(mtime).strftime("%d.%m.%Y")
|
.block{margin-bottom:28px;border-radius:10px;overflow:hidden;border:1px solid #1e1e2e}
|
||||||
href_direct = SAVETV_DIRECT_BASE.rstrip("/") + "/files/" + _urlquote(name)
|
.block-header{display:flex;align-items:center;gap:10px;padding:14px 18px;font-weight:700;font-size:15px}
|
||||||
href_tunnel = SAVETV_TUNNEL_BASE.rstrip("/") + "/files/" + _urlquote(name)
|
.block-header .count{margin-left:auto;font-size:13px;font-weight:400;color:#666}
|
||||||
rows += (
|
.bh-green{background:#0d1f14;border-bottom:1px solid #1e2e20}
|
||||||
'<tr data-name="' + _html.escape(clean.lower(), quote=True) + '" '
|
.bh-orange{background:#1f1a0d;border-bottom:1px solid #2e260e}
|
||||||
'data-date="' + str(int(mtime)) + '" '
|
.bh-blue{background:#0d1220;border-bottom:1px solid #1a2030}
|
||||||
'data-size="' + str(size) + '">'
|
.film{display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid #16161e;transition:.12s}
|
||||||
'<td style="padding:16px 20px;font-size:18px;font-weight:500">' + clean + '</td>'
|
.film:last-child{border-bottom:none}
|
||||||
'<td style="padding:16px 20px;color:#666;font-size:14px;white-space:nowrap">' + date_str + '</td>'
|
.film:hover{background:#131320}
|
||||||
'<td style="padding:16px 20px;color:#888;font-size:15px;white-space:nowrap">' + str(size) + ' MB</td>'
|
.film-name{flex:1;font-size:15px;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
'<td style="padding:16px 20px;white-space:nowrap">'
|
.film-meta{font-size:12px;color:#555;white-space:nowrap;flex-shrink:0}
|
||||||
'<a href="' + href_direct + '" download="' + esc + '" '
|
.badge{font-size:11px;font-weight:700;padding:3px 8px;border-radius:4px;white-space:nowrap;flex-shrink:0}
|
||||||
'style="background:#4caf92;color:#000;font-weight:700;padding:8px 20px;border-radius:5px;text-decoration:none;font-size:15px">'
|
.badge-green{background:#0d2a18;color:#4caf92;border:1px solid #2a5a3a}
|
||||||
'⬇ Download</a> '
|
.badge-orange{background:#2a1e08;color:#ffa726;border:1px solid #5a3a10}
|
||||||
'<a href="' + href_tunnel + '" title="Cloudflare-Tunnel" '
|
.badge-blue{background:#0a1830;color:#5a9af8;border:1px solid #1a3060}
|
||||||
'style="color:#5a7fa8;font-size:13px;font-weight:600;margin-left:8px;text-decoration:none">CF</a> '
|
.empty{padding:20px 18px;color:#444;font-style:italic;font-size:14px}
|
||||||
'<button data-file="' + esc + '" onclick="delFile(this)" '
|
.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}
|
||||||
'style="background:#ff3d3d;color:#fff;font-weight:700;padding:8px 16px;border-radius:5px;border:none;cursor:pointer;font-size:15px;margin-left:10px">'
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
'🗑 L\u00f6schen</button>'
|
.del-btn{background:none;border:none;color:#444;cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px;transition:.12s;flex-shrink:0}
|
||||||
'</td></tr>'
|
.del-btn:hover{color:#ff5555;background:#2a1010}
|
||||||
)
|
.refresh{color:#444;font-size:12px;margin-left:auto}
|
||||||
return (
|
</style>
|
||||||
'<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Downloads</title>'
|
</head>
|
||||||
'<style>'
|
<body>
|
||||||
'*{box-sizing:border-box}'
|
<nav>
|
||||||
'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}'
|
<a href="/">← Archiv</a>
|
||||||
'h1{font-size:28px;margin-bottom:8px;font-weight:700;letter-spacing:-0.5px}'
|
<a href="/downloads" class="active">▶ Pipeline</a>
|
||||||
'.sub{color:#777;font-size:15px;margin-bottom:16px}'
|
<a href="/status">⚙ Status</a>
|
||||||
'.sortbar{display:flex;align-items:center;gap:8px;margin-bottom:24px;flex-wrap:wrap}'
|
</nav>
|
||||||
'.sortbar span{color:#666;font-size:14px;margin-right:4px}'
|
<h1>▶ SaveTV Pipeline</h1>
|
||||||
'.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}'
|
<div class="meta" id="meta">Lade...</div>
|
||||||
'.sbtn:hover{background:#252538;color:#ccc;border-color:#3a3a5a}'
|
|
||||||
'.sbtn.active{background:#1a2a3a;color:#4caf92;border-color:#4caf92}'
|
<div class="block">
|
||||||
'.sbtn .arrow{margin-left:5px;font-size:11px}'
|
<div class="block-header bh-orange">
|
||||||
'table{border-collapse:collapse;width:100%;max-width:1100px}'
|
<span>⏰ Wartet auf NAS-Transfer</span>
|
||||||
'tr:hover{background:#1a1a24}'
|
<span class="count" id="cnt-pending">–</span>
|
||||||
'tr{border-bottom:1px solid #2a2a3a}'
|
</div>
|
||||||
'</style></head><body>'
|
<div id="list-pending"><div class="empty">Lade...</div></div>
|
||||||
+ nav +
|
</div>
|
||||||
'<h1>📁 Gespeicherte Filme</h1>'
|
|
||||||
'<div class="sub">' + str(len(files)) + ' Dateien · ' + str(total_gb) + ' GB</div>'
|
<div class="block">
|
||||||
'<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">'
|
<div class="block-header bh-green">
|
||||||
'Direkt vom Hetzner (ohne Cloudflare). '
|
<span>💾 Auf Hetzner bereit</span>
|
||||||
'Credentials sind im Link eingebettet – Browser-Auth-Dialog sollte nicht erscheinen. '
|
<span class="count" id="cnt-hetzner">–</span>
|
||||||
'Fallback-Link <span style="color:#888">CF</span> pro Zeile nutzt den Cloudflare-Tunnel '
|
</div>
|
||||||
'(<code style="color:#e0e0e8">' + _html.escape(SAVETV_TUNNEL_BASE, quote=True) + '</code>).'
|
<div id="list-hetzner"><div class="empty">Lade...</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<div class="block-header bh-blue">
|
||||||
|
<span>✓ 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>'
|
'</div>'
|
||||||
'<div class="sortbar"><span>Sortieren:</span>'
|
).join('');}
|
||||||
'<button class="sbtn active" data-sort="date" data-dir="desc" onclick="sortBy(this)">Datum <span class="arrow">▼</span></button>'
|
|
||||||
'<button class="sbtn" data-sort="name" data-dir="asc" onclick="sortBy(this)">Name <span class="arrow">▲</span></button>'
|
// Hetzner bereit
|
||||||
'<button class="sbtn" data-sort="size" data-dir="desc" onclick="sortBy(this)">Größe <span class="arrow">▼</span></button>'
|
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)">🗑</button>'+
|
||||||
'</div>'
|
'</div>'
|
||||||
'<table id="ftable"><tbody id="fbody">' + rows + '</tbody></table>'
|
).join('');}
|
||||||
'<script>'
|
|
||||||
'function sortBy(btn){'
|
// NAS done
|
||||||
' var key=btn.getAttribute("data-sort");'
|
document.getElementById('cnt-done').textContent=d.transferred.length+' Film'+(d.transferred.length!==1?'e':'');
|
||||||
' var dir=btn.getAttribute("data-dir");'
|
var hd=document.getElementById('list-done');
|
||||||
' var prev=document.querySelector(".sbtn.active");'
|
if(!d.transferred.length){hd.innerHTML='<div class="empty">Noch keine Filme auf NAS angekommen</div>';}
|
||||||
' if(prev&&prev===btn){dir=dir==="asc"?"desc":"asc";btn.setAttribute("data-dir",dir)}'
|
else{hd.innerHTML=d.transferred.map(f=>
|
||||||
' else{if(prev)prev.classList.remove("active");btn.classList.add("active")}'
|
'<div class="film">'+
|
||||||
' btn.querySelector(".arrow").innerHTML=dir==="asc"?"▲":"▼";'
|
'<div class="film-name">'+f.clean+'</div>'+
|
||||||
' var tb=document.getElementById("fbody");'
|
'<div class="film-meta">'+fmtTs(f.synced_at)+'</div>'+
|
||||||
' var rows=Array.from(tb.querySelectorAll("tr"));'
|
'<span class="badge badge-blue">✓ NAS</span>'+
|
||||||
' rows.sort(function(a,b){'
|
'</div>'
|
||||||
' var av,bv;'
|
).join('');}
|
||||||
' if(key==="date"){av=parseInt(a.getAttribute("data-date"));bv=parseInt(b.getAttribute("data-date"))}'
|
}).catch(e=>{
|
||||||
' else if(key==="size"){av=parseFloat(a.getAttribute("data-size"));bv=parseFloat(b.getAttribute("data-size"))}'
|
document.getElementById('meta').textContent='Fehler beim Laden: '+e;
|
||||||
' 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'
|
}
|
||||||
' });'
|
load();
|
||||||
' rows.forEach(function(r){tb.appendChild(r)})'
|
setInterval(load,30000);
|
||||||
'}'
|
</script>
|
||||||
'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)}})}'
|
</body>
|
||||||
'</script></body></html>'
|
</html>'''
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
FILMINFO_CACHE = Path("/mnt/savetv/.filminfo_cache.json")
|
FILMINFO_CACHE = Path("/mnt/savetv/.filminfo_cache.json")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue