feat: Download-Fortschritt live anzeigen mit Progressbar

This commit is contained in:
Homelab Cursor 2026-03-20 20:27:22 +01:00
parent f7747688cc
commit 4c51dbcae7

View file

@ -5,9 +5,12 @@ Erreichbar via Tailscale: http://100.123.47.7:8765
"""
import os
import re as _re
import subprocess
import sys
import json
import threading
import urllib.request
from datetime import datetime
from pathlib import Path
@ -38,6 +41,35 @@ def _save_download_log(log):
pass
DOWNLOAD_PROGRESS = Path("/mnt/savetv/.download_progress.json")
DOWNLOAD_DIR = Path("/mnt/savetv")
def _load_progress():
if DOWNLOAD_PROGRESS.exists():
try:
return json.loads(DOWNLOAD_PROGRESS.read_text())
except Exception:
pass
return {}
def _save_progress(prog):
try:
DOWNLOAD_PROGRESS.write_text(json.dumps(prog, ensure_ascii=False, indent=2))
except Exception:
pass
def _head_content_length(url):
try:
req = urllib.request.Request(url, method='HEAD')
with urllib.request.urlopen(req, timeout=10) as resp:
return int(resp.headers.get('Content-Length', 0))
except Exception:
return 0
HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
@ -295,6 +327,24 @@ HTML = r"""<!DOCTYPE html>
.search-input:focus { border-color: var(--accent); }
.dl-status { font-size: 15px; color: #ffa726; margin-top: 6px; }
.dl-done { color: var(--kino); }
.dl-progress { margin-top: 10px; }
.progress-bar {
background: var(--border);
border-radius: 4px;
height: 8px;
overflow: hidden;
margin-bottom: 6px;
}
.progress-fill {
background: linear-gradient(90deg, #ffa726, #ff8f00);
height: 100%;
border-radius: 4px;
transition: width .6s ease;
min-width: 2px;
}
.progress-fill.done { background: linear-gradient(90deg, var(--kino), #2e9e6a); }
.progress-text { font-size: 14px; color: #ffa726; }
.progress-text.done { color: var(--kino); }
</style>
</head>
<body>
@ -361,39 +411,42 @@ let currentFilter = 'all';
let filmInfoCache = {};
let infoQueue = [];
let infoLoading = false;
let dlProgress = {};
let polling = false;
function queueInfoLoad(films) {
films.forEach(f => {
if (!filmInfoCache[f.tid] && !infoQueue.some(q => q.tid === f.tid)) {
infoQueue.push({tid: f.tid, title: f.title});
}
});
if (!infoLoading) processInfoQueue();
}
async function processInfoQueue() {
if (!infoQueue.length) { infoLoading = false; return; }
async function loadFilmInfo(films) {
if (infoLoading) return;
var missing = films.filter(function(f) { return !filmInfoCache[f.tid]; });
if (!missing.length) return;
infoLoading = true;
const item = infoQueue.shift();
var titles = missing.map(function(f) { return f.title; });
try {
const r = await fetch('/api/filminfo?title=' + encodeURIComponent(item.title));
const d = await r.json();
let parts = [];
if (d.year) parts.push(d.year);
if (d.countries && d.countries.length) parts.push(d.countries.join('/'));
if (d.genres && d.genres.length) parts.push(d.genres.join(', '));
if (parts.length) {
filmInfoCache[item.tid] = parts.join(' \u00b7 ');
const el = document.getElementById('info-' + item.tid);
if (el) { el.textContent = filmInfoCache[item.tid]; el.classList.add('loaded'); }
}
var r = await fetch('/api/filminfo_batch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({titles: titles})
});
var data = await r.json();
missing.forEach(function(f) {
var d = data[f.title];
if (!d) return;
var parts = [];
if (d.year) parts.push(d.year);
if (d.countries && d.countries.length) parts.push(d.countries.join('/'));
if (d.genres && d.genres.length) parts.push(d.genres.join(', '));
if (parts.length) {
filmInfoCache[f.tid] = parts.join(' \u00b7 ');
var el = document.getElementById('info-' + f.tid);
if (el) { el.textContent = filmInfoCache[f.tid]; el.classList.add('loaded'); }
}
});
} catch(e) {}
setTimeout(processInfoQueue, 200);
infoLoading = false;
}
async function loadFilms() {
const resp = await fetch('/api/films');
const data = await resp.json();
var resp = await fetch('/api/films');
var data = await resp.json();
allFilms = data.films;
downloads = data.downloads || {};
document.getElementById('loading').style.display = 'none';
@ -401,6 +454,8 @@ async function loadFilms() {
document.getElementById('header-stats').textContent =
data.total + ' Aufnahmen \u00b7 ' + data.kino + ' Kino \u00b7 ' + data.urgent + ' dringend';
renderFilms();
var hasRunning = Object.values(downloads).some(function(v) { return v === 'running'; });
if (hasRunning) startPolling();
}
function setFilter(f) {
@ -457,8 +512,18 @@ function filmCard(f) {
var statusEl = '<div class="checkbox-indicator">' + (sel ? '\u2713' : '') + '</div>';
var extraLine = '';
if (dlState === 'running') {
var prog = dlProgress[String(f.tid)];
statusEl = '<div class="status-icon">\u2b07</div>';
extraLine = '<div class="dl-status">Download l\u00e4uft...</div>';
if (prog && prog.expected_mb > 0) {
extraLine = '<div class="dl-progress">'
+ '<div class="progress-bar"><div class="progress-fill" style="width:' + prog.percent + '%"></div></div>'
+ '<span class="progress-text">' + prog.current_mb + ' / ' + prog.expected_mb + ' MB (' + prog.percent + '%)</span>'
+ '</div>';
} else if (prog) {
extraLine = '<div class="dl-status">' + prog.current_mb + ' MB heruntergeladen...</div>';
} else {
extraLine = '<div class="dl-status">Download startet...</div>';
}
} else if (dlState === 'done') {
statusEl = '<div class="status-icon">\u2705</div>';
extraLine = '<div class="dl-status dl-done">Gespeichert</div>';
@ -521,12 +586,46 @@ async function startDownloads() {
body: JSON.stringify({tids: tids})
});
var result = await resp.json();
var errors = 0;
result.results.forEach(function(r) {
downloads[r.tid] = r.ok ? 'done' : 'error';
if (!r.ok) { downloads[r.tid] = 'error'; errors++; }
});
renderFilms();
var ok = result.results.filter(function(r) { return r.ok; }).length;
showToast('\u2705 ' + ok + '/' + tids.length + ' Downloads gestartet');
var started = result.results.length - errors;
showToast('\u2b07 ' + started + ' Download(s) gestartet');
if (started > 0) startPolling();
}
async function pollProgress() {
try {
var r = await fetch('/api/download_progress');
var data = await r.json();
var anyActive = false;
for (var tid in data) {
var info = data[tid];
dlProgress[tid] = info;
if (info.done) {
downloads[parseInt(tid)] = 'done';
delete dlProgress[tid];
} else {
anyActive = true;
}
}
renderFilms();
if (anyActive) {
setTimeout(pollProgress, 2000);
} else {
polling = false;
}
} catch(e) {
setTimeout(pollProgress, 3000);
}
}
function startPolling() {
if (polling) return;
polling = true;
setTimeout(pollProgress, 1500);
}
function showToast(msg) {
@ -596,6 +695,7 @@ def api_download():
data = request.get_json()
tids = data.get("tids", [])
dl_log = _load_download_log()
progress = _load_progress()
results = []
films_by_tid = {}
@ -608,11 +708,40 @@ def api_download():
def download_one(tid):
title = films_by_tid.get(tid, f"film_{tid}")
filename, err = savetv._download_film(tid, title)
url, err = savetv._get_download_url(tid)
if err:
app.logger.error("Download TID %s: %s", tid, err)
return {"tid": tid, "ok": False, "error": err}
dl_log[str(tid)] = "done"
try:
url, err = savetv._get_download_url(tid, fmt=savetv.DOWNLOAD_FORMAT_SD)
except AttributeError:
pass
if err:
return {"tid": tid, "ok": False, "error": f"URL-Fehler: {err}"}
expected_bytes = _head_content_length(url)
safe_title = _re.sub(r'[^\w\-.]', '_', title)[:80]
filename = f"{safe_title}_{tid}.mp4"
target = DOWNLOAD_DIR / filename
progress[str(tid)] = {
"filename": filename,
"expected_bytes": expected_bytes,
"started_at": datetime.now().isoformat(),
}
_save_progress(progress)
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
try:
subprocess.Popen(
["wget", "-q", "-O", str(target), url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
progress.pop(str(tid), None)
_save_progress(progress)
return {"tid": tid, "ok": False, "error": f"wget: {e}"}
dl_log[str(tid)] = "running"
return {"tid": tid, "ok": True, "filename": filename}
threads = []
@ -628,7 +757,7 @@ def api_download():
threads.append(t)
for t in threads:
t.join(timeout=15)
t.join(timeout=30)
results = [r for r in result_list if r]
_save_download_log(dl_log)