Die filmCard-Funktion rendert jetzt die angereicherten Daten: - 3-6 Sätze Beschreibung - Hauptdarsteller + Regisseur - Land, Jahr, Genre als Metadaten-Zeile
827 lines
24 KiB
Python
827 lines
24 KiB
Python
"""Save.TV Web-UI — Film-Archiv durchsuchen und downloaden.
|
|
|
|
Läuft auf Port 8765 in CT 116.
|
|
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
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
sys.path.insert(0, '/opt')
|
|
|
|
from flask import Flask, jsonify, render_template_string, request
|
|
from tools import savetv
|
|
|
|
app = Flask(__name__)
|
|
|
|
DOWNLOAD_LOG = Path("/mnt/savetv/.download_log.json")
|
|
|
|
|
|
def _load_download_log():
|
|
if DOWNLOAD_LOG.exists():
|
|
try:
|
|
return json.loads(DOWNLOAD_LOG.read_text())
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def _save_download_log(log):
|
|
try:
|
|
DOWNLOAD_LOG.write_text(json.dumps(log, ensure_ascii=False, indent=2))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
DOWNLOAD_PROGRESS = Path("/mnt/savetv/.download_progress.json")
|
|
DOWNLOAD_DIR = Path("/mnt/savetv")
|
|
_PROGRESS_LOCK = threading.Lock()
|
|
|
|
|
|
def _load_progress_raw():
|
|
"""Liest Progress-Datei ohne Lock (nur innerhalb von _PROGRESS_LOCK aufrufen)."""
|
|
if DOWNLOAD_PROGRESS.exists():
|
|
try:
|
|
return json.loads(DOWNLOAD_PROGRESS.read_text())
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def _save_progress_raw(prog):
|
|
"""Schreibt Progress-Datei ohne Lock (nur innerhalb von _PROGRESS_LOCK aufrufen)."""
|
|
DOWNLOAD_PROGRESS.write_text(json.dumps(prog, ensure_ascii=False, indent=2))
|
|
|
|
|
|
def _load_progress():
|
|
with _PROGRESS_LOCK:
|
|
return _load_progress_raw()
|
|
|
|
|
|
def _save_progress(prog):
|
|
with _PROGRESS_LOCK:
|
|
try:
|
|
_save_progress_raw(prog)
|
|
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>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Save.TV Archiv</title>
|
|
<style>
|
|
:root {
|
|
--bg: #1a1a2e;
|
|
--surface: #222240;
|
|
--border: #33335a;
|
|
--accent: #e84040;
|
|
--text: #ffffff;
|
|
--muted: #b8b8d0;
|
|
--urgent: #ff4444;
|
|
--kino: #44cc88;
|
|
--tv: #6699cc;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
font-size: 17px;
|
|
line-height: 1.6;
|
|
}
|
|
header {
|
|
background: var(--surface);
|
|
border-bottom: 2px solid var(--border);
|
|
padding: 20px 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 28px;
|
|
}
|
|
.logo {
|
|
font-weight: 800;
|
|
font-size: 26px;
|
|
}
|
|
.logo span { color: var(--accent); }
|
|
nav { display: flex; gap: 12px; }
|
|
nav a {
|
|
color: var(--muted);
|
|
text-decoration: none;
|
|
font-size: 15px;
|
|
padding: 6px 16px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
transition: all .2s;
|
|
}
|
|
nav a:hover { color: #fff; border-color: var(--accent); background: rgba(232,64,64,.1); }
|
|
.stats { color: var(--muted); font-size: 15px; }
|
|
main { max-width: 1100px; margin: 0 auto; padding: 32px 24px; }
|
|
.section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin: 36px 0 16px;
|
|
font-weight: 700;
|
|
font-size: 20px;
|
|
}
|
|
.badge {
|
|
padding: 4px 12px;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
}
|
|
.badge-urgent { background: var(--urgent); color: #fff; }
|
|
.badge-kino { background: var(--kino); color: #000; }
|
|
.badge-tv { background: var(--tv); color: #fff; }
|
|
.film-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
.film-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 20px 22px;
|
|
cursor: pointer;
|
|
transition: border-color .15s, background .15s;
|
|
position: relative;
|
|
}
|
|
.film-card:hover { border-color: var(--accent); background: #2a2a4a; }
|
|
.film-card.selected {
|
|
border-color: var(--accent);
|
|
background: #2e1a1a;
|
|
box-shadow: inset 0 0 0 1px var(--accent);
|
|
}
|
|
.film-card.downloaded { border-color: var(--kino); opacity: .55; }
|
|
.film-card.downloading { border-color: #ffa726; }
|
|
.film-title {
|
|
font-weight: 700;
|
|
font-size: 19px;
|
|
line-height: 1.4;
|
|
margin-bottom: 8px;
|
|
color: #fff;
|
|
}
|
|
.film-meta {
|
|
color: var(--muted);
|
|
font-size: 15px;
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
.film-details {
|
|
color: #9898b8;
|
|
font-size: 13.5px;
|
|
margin-top: 8px;
|
|
line-height: 1.5;
|
|
opacity: 0;
|
|
transition: opacity .4s;
|
|
}
|
|
.film-details.loaded { opacity: 1; }
|
|
.film-desc {
|
|
color: #a8a8c4;
|
|
font-size: 13.5px;
|
|
line-height: 1.55;
|
|
margin-top: 6px;
|
|
}
|
|
.film-crew {
|
|
color: #8888a8;
|
|
font-size: 12.5px;
|
|
margin-top: 5px;
|
|
}
|
|
.days-badge {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
padding: 3px 10px;
|
|
border-radius: 4px;
|
|
margin-left: auto;
|
|
}
|
|
.days-urgent { background: var(--urgent); color: #fff; }
|
|
.days-ok { background: var(--border); color: var(--muted); }
|
|
.checkbox-indicator {
|
|
position: absolute;
|
|
top: 14px;
|
|
right: 14px;
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px solid var(--border);
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 14px;
|
|
transition: all .15s;
|
|
}
|
|
.film-card.selected .checkbox-indicator {
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
color: #fff;
|
|
}
|
|
.status-icon {
|
|
position: absolute;
|
|
top: 12px;
|
|
right: 12px;
|
|
font-size: 18px;
|
|
}
|
|
.toolbar {
|
|
position: sticky;
|
|
bottom: 0;
|
|
background: var(--surface);
|
|
border-top: 2px solid var(--border);
|
|
padding: 16px 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
z-index: 100;
|
|
}
|
|
.selected-count {
|
|
font-weight: 700;
|
|
font-size: 18px;
|
|
}
|
|
.selected-count span { color: var(--accent); }
|
|
.btn {
|
|
padding: 12px 24px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-weight: 700;
|
|
font-size: 15px;
|
|
cursor: pointer;
|
|
transition: opacity .15s;
|
|
text-transform: uppercase;
|
|
letter-spacing: .5px;
|
|
}
|
|
.btn:hover { opacity: .85; }
|
|
.btn:disabled { opacity: .3; cursor: not-allowed; }
|
|
.btn-primary { background: var(--accent); color: #fff; }
|
|
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
|
.btn-select-urgent { background: #2a0d0d; color: var(--urgent); border: 1px solid var(--urgent); }
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 200px;
|
|
color: var(--muted);
|
|
gap: 12px;
|
|
flex-direction: column;
|
|
font-size: 17px;
|
|
}
|
|
.spinner {
|
|
width: 36px;
|
|
height: 36px;
|
|
border: 3px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 80px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: #1a2a1a;
|
|
border: 1px solid var(--kino);
|
|
color: var(--kino);
|
|
padding: 14px 24px;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
opacity: 0;
|
|
transition: opacity .3s;
|
|
z-index: 200;
|
|
pointer-events: none;
|
|
}
|
|
.toast.show { opacity: 1; }
|
|
.filter-bar {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin: 20px 0 12px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
.filter-btn {
|
|
padding: 8px 18px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
background: transparent;
|
|
color: var(--muted);
|
|
cursor: pointer;
|
|
font-size: 15px;
|
|
transition: all .15s;
|
|
}
|
|
.filter-btn.active { border-color: var(--accent); color: #fff; background: rgba(232,64,64,.15); }
|
|
.search-input {
|
|
padding: 8px 16px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
border-radius: 6px;
|
|
font-size: 16px;
|
|
outline: none;
|
|
width: 240px;
|
|
transition: border-color .15s;
|
|
}
|
|
.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>
|
|
<header>
|
|
<div class="header-left">
|
|
<div class="logo">Save<span>.</span>TV</div>
|
|
<nav>
|
|
<a href="/downloads">▼ Downloads</a>
|
|
<a href="/status">⚙ Status</a>
|
|
</nav>
|
|
</div>
|
|
<div class="stats" id="header-stats">Lade...</div>
|
|
</header>
|
|
<main>
|
|
<div id="loading" class="loading">
|
|
<div class="spinner"></div>
|
|
<div>Lade Archiv von Save.TV...</div>
|
|
</div>
|
|
<div id="content" style="display:none">
|
|
<div class="filter-bar">
|
|
<button class="filter-btn active" onclick="setFilter('all')">Alle</button>
|
|
<button class="filter-btn" onclick="setFilter('urgent')">🔴 Dringend</button>
|
|
<button class="filter-btn" onclick="setFilter('kino')">🎬 Kino</button>
|
|
<button class="filter-btn" onclick="setFilter('tv')">📺 TV-Film</button>
|
|
<input class="search-input" id="search" placeholder="Suchen..." oninput="renderFilms()">
|
|
</div>
|
|
<div id="urgent-section" style="display:none">
|
|
<div class="section-header">
|
|
<span>🔴 Dringend</span>
|
|
<span class="badge badge-urgent" id="urgent-count">0</span>
|
|
<span style="color:var(--muted);font-size:15px;margin-left:6px">laufen bald ab</span>
|
|
</div>
|
|
<div class="film-grid" id="urgent-grid"></div>
|
|
</div>
|
|
<div id="kino-section" style="display:none">
|
|
<div class="section-header">
|
|
<span>🎬 Kino-Highlights</span>
|
|
<span class="badge badge-kino" id="kino-count">0</span>
|
|
</div>
|
|
<div class="film-grid" id="kino-grid"></div>
|
|
</div>
|
|
<div id="tv-section" style="display:none">
|
|
<div class="section-header">
|
|
<span>📺 Deutsche TV-Filme</span>
|
|
<span class="badge badge-tv" id="tv-count">0</span>
|
|
</div>
|
|
<div class="film-grid" id="tv-grid"></div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
<div class="toolbar">
|
|
<div class="selected-count"><span id="sel-count">0</span> ausgewählt</div>
|
|
<button class="btn btn-select-urgent" onclick="selectUrgent()">🔴 Alle Dringenden</button>
|
|
<button class="btn btn-ghost" onclick="clearSelection()">Abwählen</button>
|
|
<button class="btn btn-primary" id="dl-btn" onclick="startDownloads()" disabled>⬇ Download starten</button>
|
|
</div>
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
let allFilms = [];
|
|
let selected = new Set();
|
|
let downloads = {};
|
|
let currentFilter = 'all';
|
|
let filmInfoCache = {};
|
|
let infoQueue = [];
|
|
let infoLoading = false;
|
|
let dlProgress = {};
|
|
let polling = false;
|
|
|
|
function esc(s) {
|
|
var d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
async function loadFilmInfo(films) {
|
|
if (infoLoading) return;
|
|
var missing = films.filter(function(f) { return !filmInfoCache[f.tid]; });
|
|
if (!missing.length) return;
|
|
infoLoading = true;
|
|
var titles = missing.map(function(f) { return f.title; });
|
|
try {
|
|
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;
|
|
filmInfoCache[f.tid] = d;
|
|
var el = document.getElementById('details-' + f.tid);
|
|
if (el) {
|
|
el.innerHTML = buildDetails(d);
|
|
el.classList.add('loaded');
|
|
}
|
|
});
|
|
} catch(e) {}
|
|
infoLoading = false;
|
|
}
|
|
|
|
function buildDetails(d) {
|
|
if (!d) return '';
|
|
var parts = [];
|
|
if (d.year) parts.push(esc(d.year));
|
|
if (d.countries && d.countries.length) parts.push(esc(d.countries.join('/')));
|
|
if (d.genres && d.genres.length) parts.push(esc(d.genres.join(', ')));
|
|
var metaLine = parts.length ? '<div style="margin-bottom:4px">' + parts.join(' \u00b7 ') + '</div>' : '';
|
|
var descLine = d.description ? '<div class="film-desc">' + esc(d.description) + '</div>' : '';
|
|
var crewParts = [];
|
|
if (d.actors && d.actors.length) crewParts.push('Mit ' + esc(d.actors.join(', ')));
|
|
if (d.director) crewParts.push('Regie: ' + esc(d.director));
|
|
var crewLine = crewParts.length ? '<div class="film-crew">' + crewParts.join(' \u00b7 ') + '</div>' : '';
|
|
return metaLine + descLine + crewLine;
|
|
}
|
|
|
|
async function loadFilms() {
|
|
var resp = await fetch('/api/films');
|
|
var data = await resp.json();
|
|
allFilms = data.films;
|
|
downloads = data.downloads || {};
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('content').style.display = 'block';
|
|
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) {
|
|
currentFilter = f;
|
|
document.querySelectorAll('.filter-btn').forEach(function(b, i) {
|
|
b.classList.toggle('active', ['all','urgent','kino','tv'][i] === f);
|
|
});
|
|
renderFilms();
|
|
}
|
|
|
|
function renderFilms() {
|
|
var q = document.getElementById('search').value.toLowerCase();
|
|
var films = allFilms.filter(function(f) {
|
|
if (q && f.title.toLowerCase().indexOf(q) === -1) return false;
|
|
if (currentFilter === 'urgent') return f.days_left <= 7;
|
|
if (currentFilter === 'kino') return f.cinema && f.days_left > 7;
|
|
if (currentFilter === 'tv') return !f.cinema;
|
|
return true;
|
|
});
|
|
|
|
var urgent = films.filter(function(f) { return f.days_left <= 7; });
|
|
var kino = films.filter(function(f) { return f.cinema && f.days_left > 7; });
|
|
var tv = films.filter(function(f) { return !f.cinema && f.days_left > 7; });
|
|
|
|
document.getElementById('urgent-count').textContent = urgent.length;
|
|
document.getElementById('kino-count').textContent = kino.length;
|
|
document.getElementById('tv-count').textContent = tv.length;
|
|
|
|
function renderGrid(arr, gridId, sectionId) {
|
|
var el = document.getElementById(sectionId);
|
|
var grid = document.getElementById(gridId);
|
|
if (!arr.length) { el.style.display = 'none'; return; }
|
|
el.style.display = 'block';
|
|
grid.innerHTML = arr.map(function(f) { return filmCard(f); }).join('');
|
|
}
|
|
|
|
renderGrid(urgent, 'urgent-grid', 'urgent-section');
|
|
renderGrid(kino, 'kino-grid', 'kino-section');
|
|
renderGrid(tv, 'tv-grid', 'tv-section');
|
|
loadFilmInfo(allFilms);
|
|
}
|
|
|
|
function filmCard(f) {
|
|
var sel = selected.has(f.tid);
|
|
var dlState = downloads[f.tid];
|
|
var daysClass = f.days_left <= 3 ? 'days-urgent' : 'days-ok';
|
|
var daysLabel = f.days_left === 0 ? 'heute' : f.days_left === 1 ? '1 Tag' : f.days_left + ' Tage';
|
|
|
|
var cardClass = 'film-card';
|
|
if (sel) cardClass += ' selected';
|
|
if (dlState === 'done') cardClass += ' downloaded';
|
|
if (dlState === 'running') cardClass += ' downloading';
|
|
|
|
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>';
|
|
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>';
|
|
}
|
|
|
|
var cached = filmInfoCache[f.tid];
|
|
var detailsHtml = cached ? buildDetails(cached) : '';
|
|
var detailsClass = detailsHtml ? 'film-details loaded' : 'film-details';
|
|
|
|
return '<div class="' + cardClass + '" onclick="toggleFilm(' + f.tid + ')" data-tid="' + f.tid + '">'
|
|
+ statusEl
|
|
+ '<div class="film-title">' + esc(f.title) + '</div>'
|
|
+ '<div class="film-meta">'
|
|
+ '<span>' + esc(f.station) + '</span>'
|
|
+ '<span class="badge ' + (f.cinema ? 'badge-kino' : 'badge-tv') + '">' + (f.cinema ? 'Kino' : 'TV') + '</span>'
|
|
+ '<span class="days-badge ' + daysClass + '">' + daysLabel + '</span>'
|
|
+ '</div>'
|
|
+ '<div class="' + detailsClass + '" id="details-' + f.tid + '">' + detailsHtml + '</div>'
|
|
+ extraLine
|
|
+ '</div>';
|
|
}
|
|
|
|
function toggleFilm(tid) {
|
|
if (downloads[tid] === 'done' || downloads[tid] === 'running') return;
|
|
if (selected.has(tid)) selected.delete(tid);
|
|
else selected.add(tid);
|
|
updateToolbar();
|
|
renderFilms();
|
|
}
|
|
|
|
function updateToolbar() {
|
|
document.getElementById('sel-count').textContent = selected.size;
|
|
document.getElementById('dl-btn').disabled = selected.size === 0;
|
|
}
|
|
|
|
function selectUrgent() {
|
|
allFilms.filter(function(f) { return f.days_left <= 7 && downloads[f.tid] !== 'done'; }).forEach(function(f) { selected.add(f.tid); });
|
|
updateToolbar();
|
|
renderFilms();
|
|
}
|
|
|
|
function clearSelection() {
|
|
selected.clear();
|
|
updateToolbar();
|
|
renderFilms();
|
|
}
|
|
|
|
async function startDownloads() {
|
|
var tids = Array.from(selected);
|
|
if (!tids.length) return;
|
|
document.getElementById('dl-btn').disabled = true;
|
|
showToast('Starte ' + tids.length + ' Download(s)...');
|
|
tids.forEach(function(tid) { downloads[tid] = 'running'; });
|
|
selected.clear();
|
|
updateToolbar();
|
|
renderFilms();
|
|
|
|
var resp = await fetch('/api/download', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({tids: tids})
|
|
});
|
|
var result = await resp.json();
|
|
var errors = 0;
|
|
result.results.forEach(function(r) {
|
|
if (!r.ok) { downloads[r.tid] = 'error'; errors++; }
|
|
});
|
|
renderFilms();
|
|
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) {
|
|
var t = document.getElementById('toast');
|
|
t.textContent = msg;
|
|
t.classList.add('show');
|
|
setTimeout(function() { t.classList.remove('show'); }, 3500);
|
|
}
|
|
|
|
loadFilms();
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return render_template_string(HTML)
|
|
|
|
|
|
@app.route("/api/films")
|
|
def api_films():
|
|
entries = savetv._get_full_archive()
|
|
seen_titles = {}
|
|
series_count = 0
|
|
excluded_count = 0
|
|
|
|
for e in entries:
|
|
tc = e.get("STRTELECASTENTRY", {})
|
|
if tc.get("SFOLGE", ""):
|
|
series_count += 1
|
|
continue
|
|
title = tc.get("STITLE", "?")
|
|
if savetv._is_excluded(title):
|
|
excluded_count += 1
|
|
continue
|
|
|
|
station = tc.get("STVSTATIONNAME", "?")
|
|
days_left = int(tc.get("IDAYSLEFTBEFOREDELETE", 0))
|
|
tid = int(tc.get("ITELECASTID", 0))
|
|
is_cinema = savetv._is_known_cinema(title)
|
|
key = title.lower().strip()
|
|
if key in seen_titles:
|
|
if days_left > seen_titles[key]["days_left"]:
|
|
seen_titles[key].update(days_left=days_left, tid=tid)
|
|
continue
|
|
seen_titles[key] = {
|
|
"tid": tid, "title": title, "station": station,
|
|
"days_left": days_left, "cinema": is_cinema,
|
|
}
|
|
|
|
films = sorted(seen_titles.values(), key=lambda x: (x["days_left"], not x["cinema"]))
|
|
dl_log = _load_download_log()
|
|
|
|
return jsonify({
|
|
"films": films,
|
|
"total": len(entries),
|
|
"kino": sum(1 for f in films if f["cinema"]),
|
|
"urgent": sum(1 for f in films if f["days_left"] <= 7),
|
|
"downloads": dl_log,
|
|
})
|
|
|
|
|
|
@app.route("/api/download", methods=["POST"])
|
|
def api_download():
|
|
data = request.get_json()
|
|
tids = data.get("tids", [])
|
|
dl_log = _load_download_log()
|
|
progress = _load_progress()
|
|
results = []
|
|
|
|
films_by_tid = {}
|
|
entries = savetv._get_full_archive()
|
|
for e in entries:
|
|
tc = e.get("STRTELECASTENTRY", {})
|
|
tid = int(tc.get("ITELECASTID", 0))
|
|
if tid:
|
|
films_by_tid[tid] = tc.get("STITLE", f"film_{tid}")
|
|
|
|
def download_one(tid):
|
|
title = films_by_tid.get(tid, f"film_{tid}")
|
|
url, err = savetv._get_download_url(tid)
|
|
if err:
|
|
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
|
|
|
|
with _PROGRESS_LOCK:
|
|
cur_progress = _load_progress_raw()
|
|
cur_progress[str(tid)] = {
|
|
"filename": filename,
|
|
"expected_bytes": expected_bytes,
|
|
"started_at": datetime.now().isoformat(),
|
|
}
|
|
_save_progress_raw(cur_progress)
|
|
|
|
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
subprocess.Popen(
|
|
["wget", "-q", "-O", str(target), url],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
except Exception as e:
|
|
with _PROGRESS_LOCK:
|
|
cur_progress = _load_progress_raw()
|
|
cur_progress.pop(str(tid), None)
|
|
_save_progress_raw(cur_progress)
|
|
return {"tid": tid, "ok": False, "error": f"wget: {e}"}
|
|
|
|
dl_log[str(tid)] = "running"
|
|
return {"tid": tid, "ok": True, "filename": filename}
|
|
|
|
threads = []
|
|
result_list = [None] * len(tids)
|
|
|
|
def worker(i, tid):
|
|
result_list[i] = download_one(tid)
|
|
|
|
for i, tid in enumerate(tids):
|
|
t = threading.Thread(target=worker, args=(i, tid))
|
|
t.daemon = True
|
|
t.start()
|
|
threads.append(t)
|
|
|
|
for t in threads:
|
|
t.join(timeout=30)
|
|
|
|
results = [r for r in result_list if r]
|
|
_save_download_log(dl_log)
|
|
|
|
return jsonify({"results": results})
|
|
|
|
|
|
# Extra-Routes (Downloads, Status, Health) - lokal in /opt/savetv_extra_routes.py
|
|
try:
|
|
from savetv_extra_routes import register_extra_routes
|
|
register_extra_routes(app,
|
|
progress_lock=_PROGRESS_LOCK,
|
|
load_progress_raw=_load_progress_raw,
|
|
save_progress_raw=_save_progress_raw)
|
|
except ImportError:
|
|
pass
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="0.0.0.0", port=8765, debug=False)
|