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 os
import re as _re
import subprocess
import sys import sys
import json import json
import threading import threading
import urllib.request
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -38,6 +41,35 @@ def _save_download_log(log):
pass 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 = r"""<!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
@ -295,6 +327,24 @@ HTML = r"""<!DOCTYPE html>
.search-input:focus { border-color: var(--accent); } .search-input:focus { border-color: var(--accent); }
.dl-status { font-size: 15px; color: #ffa726; margin-top: 6px; } .dl-status { font-size: 15px; color: #ffa726; margin-top: 6px; }
.dl-done { color: var(--kino); } .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> </style>
</head> </head>
<body> <body>
@ -361,39 +411,42 @@ let currentFilter = 'all';
let filmInfoCache = {}; let filmInfoCache = {};
let infoQueue = []; let infoQueue = [];
let infoLoading = false; let infoLoading = false;
let dlProgress = {};
let polling = false;
function queueInfoLoad(films) { async function loadFilmInfo(films) {
films.forEach(f => { if (infoLoading) return;
if (!filmInfoCache[f.tid] && !infoQueue.some(q => q.tid === f.tid)) { var missing = films.filter(function(f) { return !filmInfoCache[f.tid]; });
infoQueue.push({tid: f.tid, title: f.title}); if (!missing.length) return;
}
});
if (!infoLoading) processInfoQueue();
}
async function processInfoQueue() {
if (!infoQueue.length) { infoLoading = false; return; }
infoLoading = true; infoLoading = true;
const item = infoQueue.shift(); var titles = missing.map(function(f) { return f.title; });
try { try {
const r = await fetch('/api/filminfo?title=' + encodeURIComponent(item.title)); var r = await fetch('/api/filminfo_batch', {
const d = await r.json(); method: 'POST',
let parts = []; headers: {'Content-Type': 'application/json'},
if (d.year) parts.push(d.year); body: JSON.stringify({titles: titles})
if (d.countries && d.countries.length) parts.push(d.countries.join('/')); });
if (d.genres && d.genres.length) parts.push(d.genres.join(', ')); var data = await r.json();
if (parts.length) { missing.forEach(function(f) {
filmInfoCache[item.tid] = parts.join(' \u00b7 '); var d = data[f.title];
const el = document.getElementById('info-' + item.tid); if (!d) return;
if (el) { el.textContent = filmInfoCache[item.tid]; el.classList.add('loaded'); } 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) {} } catch(e) {}
setTimeout(processInfoQueue, 200); infoLoading = false;
} }
async function loadFilms() { async function loadFilms() {
const resp = await fetch('/api/films'); var resp = await fetch('/api/films');
const data = await resp.json(); var data = await resp.json();
allFilms = data.films; allFilms = data.films;
downloads = data.downloads || {}; downloads = data.downloads || {};
document.getElementById('loading').style.display = 'none'; document.getElementById('loading').style.display = 'none';
@ -401,6 +454,8 @@ async function loadFilms() {
document.getElementById('header-stats').textContent = document.getElementById('header-stats').textContent =
data.total + ' Aufnahmen \u00b7 ' + data.kino + ' Kino \u00b7 ' + data.urgent + ' dringend'; data.total + ' Aufnahmen \u00b7 ' + data.kino + ' Kino \u00b7 ' + data.urgent + ' dringend';
renderFilms(); renderFilms();
var hasRunning = Object.values(downloads).some(function(v) { return v === 'running'; });
if (hasRunning) startPolling();
} }
function setFilter(f) { function setFilter(f) {
@ -457,8 +512,18 @@ function filmCard(f) {
var statusEl = '<div class="checkbox-indicator">' + (sel ? '\u2713' : '') + '</div>'; var statusEl = '<div class="checkbox-indicator">' + (sel ? '\u2713' : '') + '</div>';
var extraLine = ''; var extraLine = '';
if (dlState === 'running') { if (dlState === 'running') {
var prog = dlProgress[String(f.tid)];
statusEl = '<div class="status-icon">\u2b07</div>'; 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') { } else if (dlState === 'done') {
statusEl = '<div class="status-icon">\u2705</div>'; statusEl = '<div class="status-icon">\u2705</div>';
extraLine = '<div class="dl-status dl-done">Gespeichert</div>'; extraLine = '<div class="dl-status dl-done">Gespeichert</div>';
@ -521,12 +586,46 @@ async function startDownloads() {
body: JSON.stringify({tids: tids}) body: JSON.stringify({tids: tids})
}); });
var result = await resp.json(); var result = await resp.json();
var errors = 0;
result.results.forEach(function(r) { result.results.forEach(function(r) {
downloads[r.tid] = r.ok ? 'done' : 'error'; if (!r.ok) { downloads[r.tid] = 'error'; errors++; }
}); });
renderFilms(); renderFilms();
var ok = result.results.filter(function(r) { return r.ok; }).length; var started = result.results.length - errors;
showToast('\u2705 ' + ok + '/' + tids.length + ' Downloads gestartet'); 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) { function showToast(msg) {
@ -596,6 +695,7 @@ def api_download():
data = request.get_json() data = request.get_json()
tids = data.get("tids", []) tids = data.get("tids", [])
dl_log = _load_download_log() dl_log = _load_download_log()
progress = _load_progress()
results = [] results = []
films_by_tid = {} films_by_tid = {}
@ -608,11 +708,40 @@ def api_download():
def download_one(tid): def download_one(tid):
title = films_by_tid.get(tid, f"film_{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: if err:
app.logger.error("Download TID %s: %s", tid, err) try:
return {"tid": tid, "ok": False, "error": err} url, err = savetv._get_download_url(tid, fmt=savetv.DOWNLOAD_FORMAT_SD)
dl_log[str(tid)] = "done" 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} return {"tid": tid, "ok": True, "filename": filename}
threads = [] threads = []
@ -628,7 +757,7 @@ def api_download():
threads.append(t) threads.append(t)
for t in threads: for t in threads:
t.join(timeout=15) t.join(timeout=30)
results = [r for r in result_list if r] results = [r for r in result_list if r]
_save_download_log(dl_log) _save_download_log(dl_log)