diff --git a/homelab-ai-bot/savetv_web.py b/homelab-ai-bot/savetv_web.py index f8f3ad80..ec65e19e 100644 --- a/homelab-ai-bot/savetv_web.py +++ b/homelab-ai-bot/savetv_web.py @@ -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""" @@ -295,6 +327,24 @@ HTML = r""" .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); } @@ -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 = '
' + (sel ? '\u2713' : '') + '
'; var extraLine = ''; if (dlState === 'running') { + var prog = dlProgress[String(f.tid)]; statusEl = '
\u2b07
'; - extraLine = '
Download l\u00e4uft...
'; + if (prog && prog.expected_mb > 0) { + extraLine = '
' + + '
' + + '' + prog.current_mb + ' / ' + prog.expected_mb + ' MB (' + prog.percent + '%)' + + '
'; + } else if (prog) { + extraLine = '
' + prog.current_mb + ' MB heruntergeladen...
'; + } else { + extraLine = '
Download startet...
'; + } } else if (dlState === 'done') { statusEl = '
\u2705
'; extraLine = '
Gespeichert
'; @@ -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)