homelab-brain/homelab-ai-bot/savetv_web.py

598 lines
17 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 sys
import json
import threading
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
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>
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;700;800&family=JetBrains+Mono:wght@400;600&display=swap');
:root {
--bg: #0e0e14;
--surface: #16161e;
--border: #2a2a3a;
--accent: #e8421a;
--accent2: #ff7043;
--text: #f0f0f5;
--muted: #9999b0;
--urgent: #ff3d3d;
--kino: #4caf92;
--tv: #5a7fa8;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
font-size: 16px;
line-height: 1.6;
min-height: 100vh;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 18px 28px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.logo {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: 24px;
letter-spacing: -0.5px;
}
.logo span { color: var(--accent); }
.stats { color: var(--muted); font-size: 14px; }
main { max-width: 1100px; margin: 0 auto; padding: 28px 20px; }
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin: 32px 0 14px;
font-family: 'Syne', sans-serif;
font-weight: 700;
font-size: 17px;
text-transform: uppercase;
letter-spacing: 1.5px;
}
.badge {
padding: 3px 10px;
border-radius: 3px;
font-size: 15px;
font-family: system-ui, sans-serif;
font-weight: 600;
}
.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(320px, 1fr));
gap: 10px;
}
.film-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 18px 20px;
cursor: pointer;
transition: border-color .15s, background .15s;
position: relative;
user-select: none;
}
.film-card:hover { border-color: var(--accent); background: #16161f; }
.film-card.selected {
border-color: var(--accent);
background: #1a0d0a;
box-shadow: inset 0 0 0 1px var(--accent);
}
.film-card.downloaded { border-color: var(--kino); opacity: .6; }
.film-card.downloading { border-color: #ffa726; }
.film-title {
font-family: 'Syne', sans-serif;
font-weight: 700;
font-size: 17px;
line-height: 1.4;
margin-bottom: 8px;
}
.film-meta {
color: var(--muted);
font-size: 14px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.days-badge {
font-size: 14px;
font-weight: 600;
padding: 3px 8px;
border-radius: 2px;
margin-left: auto;
}
.days-urgent { background: var(--urgent); color: #fff; }
.days-ok { background: var(--border); color: var(--muted); }
.status-icon {
position: absolute;
top: 10px;
right: 10px;
font-size: 16px;
}
.checkbox-indicator {
position: absolute;
top: 12px;
right: 12px;
width: 22px;
height: 22px;
border: 2px solid var(--border);
border-radius: 3px;
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;
}
.toolbar {
position: sticky;
bottom: 0;
background: var(--surface);
border-top: 1px solid var(--border);
padding: 14px 28px;
display: flex;
align-items: center;
gap: 16px;
z-index: 100;
}
.selected-count {
font-family: 'Syne', sans-serif;
font-weight: 700;
font-size: 17px;
}
.selected-count span { color: var(--accent); }
.btn {
padding: 10px 22px;
border: none;
border-radius: 4px;
font-family: 'Syne', sans-serif;
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: 10px;
flex-direction: column;
}
.spinner {
width: 32px;
height: 32px;
border: 2px 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: 12px 20px;
border-radius: 6px;
font-size: 15px;
opacity: 0;
transition: opacity .3s;
z-index: 200;
pointer-events: none;
}
.toast.show { opacity: 1; }
.filter-bar {
display: flex;
gap: 8px;
margin: 20px 0 10px;
flex-wrap: wrap;
}
.filter-btn {
padding: 5px 14px;
border-radius: 3px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
font-family: system-ui, sans-serif;
font-size: 14px;
transition: all .15s;
}
.filter-btn.active { border-color: var(--accent); color: var(--text); background: #1a0d0a; }
.search-input {
padding: 6px 14px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
border-radius: 3px;
font-family: system-ui, sans-serif;
font-size: 15px;
outline: none;
width: 220px;
transition: border-color .15s;
}
.search-input:focus { border-color: var(--accent); }
.dl-status {
font-size: 14px;
color: #ffa726;
margin-top: 4px;
}
.dl-done { color: var(--kino); }
</style>
</head>
<body>
<header>
<div class="logo">Save<span>.</span>TV <span style="color:var(--muted);font-weight:400;font-size:14px">Archiv</span></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:14px;margin-left:4px">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';
async function loadFilms() {
const resp = await fetch('/api/films');
const 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 · ${data.kino} Kino · ${data.urgent} dringend`;
renderFilms();
}
function setFilter(f) {
currentFilter = f;
document.querySelectorAll('.filter-btn').forEach((b, i) => {
b.classList.toggle('active', ['all','urgent','kino','tv'][i] === f);
});
renderFilms();
}
function renderFilms() {
const q = document.getElementById('search').value.toLowerCase();
const films = allFilms.filter(f => {
if (q && !f.title.toLowerCase().includes(q)) 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;
});
const urgent = films.filter(f => f.days_left <= 7);
const kino = films.filter(f => f.cinema && f.days_left > 7);
const tv = films.filter(f => !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) {
const el = document.getElementById(sectionId);
const grid = document.getElementById(gridId);
if (!arr.length) { el.style.display = 'none'; return; }
el.style.display = 'block';
grid.innerHTML = arr.map(f => filmCard(f)).join('');
}
renderGrid(urgent, 'urgent-grid', 'urgent-section');
renderGrid(kino, 'kino-grid', 'kino-section');
renderGrid(tv, 'tv-grid', 'tv-section');
}
function filmCard(f) {
const sel = selected.has(f.tid);
const dlState = downloads[f.tid];
const daysClass = f.days_left <= 3 ? 'days-urgent' : 'days-ok';
const daysLabel = f.days_left === 0 ? 'heute' : f.days_left === 1 ? '1 Tag' : `${f.days_left} Tage`;
let cardClass = 'film-card';
if (sel) cardClass += ' selected';
if (dlState === 'done') cardClass += ' downloaded';
if (dlState === 'running') cardClass += ' downloading';
let statusEl = `<div class="checkbox-indicator">${sel ? '' : ''}</div>`;
let extraLine = '';
if (dlState === 'running') {
statusEl = `<div class="status-icon">⬇</div>`;
extraLine = `<div class="dl-status">Download läuft...</div>`;
} else if (dlState === 'done') {
statusEl = `<div class="status-icon">✅</div>`;
extraLine = `<div class="dl-status dl-done">Gespeichert</div>`;
}
return `<div class="${cardClass}" onclick="toggleFilm(${f.tid})" data-tid="${f.tid}">
${statusEl}
<div class="film-title">${f.title}</div>
<div class="film-meta">
<span>${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>
${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(f => f.days_left <= 7 && downloads[f.tid] !== 'done').forEach(f => selected.add(f.tid));
updateToolbar();
renderFilms();
}
function clearSelection() {
selected.clear();
updateToolbar();
renderFilms();
}
async function startDownloads() {
const tids = Array.from(selected);
if (!tids.length) return;
document.getElementById('dl-btn').disabled = true;
showToast(`Starte ${tids.length} Download(s)...`);
tids.forEach(tid => { downloads[tid] = 'running'; });
selected.clear();
updateToolbar();
renderFilms();
const resp = await fetch('/api/download', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({tids})
});
const result = await resp.json();
result.results.forEach(r => {
downloads[r.tid] = r.ok ? 'done' : 'error';
});
renderFilms();
const ok = result.results.filter(r => r.ok).length;
showToast(`✅ ${ok}/${tids.length} Downloads gestartet`);
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => 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()
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}")
filename, err = savetv._download_film(tid, title)
if err:
app.logger.error("Download TID %s: %s", tid, err)
return {"tid": tid, "ok": False, "error": err}
dl_log[str(tid)] = "done"
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=15)
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)
except ImportError:
pass
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8765, debug=False)