feat: Download-Fortschritt live anzeigen mit Progressbar
This commit is contained in:
parent
f7747688cc
commit
4c51dbcae7
1 changed files with 164 additions and 35 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue