homelab-brain/homelab-ai-bot/savetv_extra_routes.py
Homelab Cursor 73332bfc14 fix(savetv): Basic-Auth in Direct-Download-URL einbetten
Credentials (mike:astral66) direkt im href-Link, damit kein Browser-Auth-Dialog erscheint.
SAVETV_DIRECT_USER/PASS als env-ueberschreibbare Konstanten.
2026-03-28 19:05:07 +01:00

686 lines
32 KiB
Python

"""Extra Routes fuer savetv_web.py - nicht im Git, lokal in CT 116."""
import html as _html
import json as _json
import os as _os
import re as _re
import shutil
import time as _time
from pathlib import Path
from urllib.parse import quote as _urlquote
from flask import send_from_directory, request, jsonify
SAVETV_DIR = Path("/mnt/savetv")
JELLYFIN_CACHE = SAVETV_DIR / ".jellyfin_cache.json"
JELLYFIN_TTL = 3600
JELLYFIN_URL = _os.environ.get("JELLYFIN_URL", "http://100.77.105.3:8096")
JELLYFIN_USER = _os.environ.get("JELLYFIN_USER", "admin")
JELLYFIN_PASS = _os.environ.get("JELLYFIN_PASS", "astral66")
# Direkter Download ohne Cloudflare (Hetzner :9443 → nginx → CT 116)
SAVETV_DIRECT_BASE = _os.environ.get("SAVETV_DIRECT_BASE", "http://138.201.84.95:9443")
SAVETV_TUNNEL_BASE = _os.environ.get("SAVETV_TUNNEL_BASE", "https://savetv.orbitalo.net")
SAVETV_DIRECT_USER = _os.environ.get("SAVETV_DIRECT_USER", "mike")
SAVETV_DIRECT_PASS = _os.environ.get("SAVETV_DIRECT_PASS", "astral66")
def _normalize_film_title(s: str) -> str:
"""Gleiche Logik wie normTitle() in savetv_web.js fuer Abgleich."""
if not s:
return ""
s = _re.sub(r"[^\w\s]", " ", s, flags=_re.UNICODE)
s = _re.sub(r"\s+", " ", s).strip().lower()
return s
def _strip_trailing_year_in_parens(name: str) -> str:
return _re.sub(r"\s*\(\d{4}\)\s*$", "", name or "").strip()
def _jellyfin_fetch_library():
"""Holt alle Film-Titel aus Jellyfin, normalisiert, mit 1h File-Cache."""
import requests as _rq
now = _time.time()
if JELLYFIN_CACHE.exists():
try:
data = _json.loads(JELLYFIN_CACHE.read_text())
if now - float(data.get("ts", 0)) < JELLYFIN_TTL:
return {
"normalized_titles": data.get("normalized_titles", []),
"count": int(data.get("count", 0)),
"cached": True,
}
except Exception:
pass
r = _rq.post(
f"{JELLYFIN_URL}/Users/AuthenticateByName",
headers={
"Content-Type": "application/json",
"X-Emby-Authorization": 'MediaBrowser Client="SaveTV", Device="CT116", DeviceId="savetv-jf", Version="1.0"',
},
json={"Username": JELLYFIN_USER, "Pw": JELLYFIN_PASS},
timeout=30,
)
r.raise_for_status()
token = r.json()["AccessToken"]
r2 = _rq.get(
f"{JELLYFIN_URL}/Items",
params={
"IncludeItemTypes": "Movie",
"Recursive": "true",
"Fields": "ProductionYear",
"Limit": 10000,
"StartIndex": 0,
},
headers={"X-Emby-Token": token},
timeout=120,
)
r2.raise_for_status()
payload = r2.json()
items = payload.get("Items", [])
normalized = set()
for it in items:
name = it.get("Name") or ""
if not name:
continue
clean = _strip_trailing_year_in_parens(name)
key = _normalize_film_title(clean)
if key:
normalized.add(key)
sorted_list = sorted(normalized)
out = {"ts": now, "normalized_titles": sorted_list, "count": len(normalized)}
try:
JELLYFIN_CACHE.write_text(_json.dumps(out, ensure_ascii=False, indent=2))
except Exception:
pass
return {"normalized_titles": sorted_list, "count": len(normalized), "cached": False}
def register_extra_routes(app, progress_lock=None, load_progress_raw=None, save_progress_raw=None):
import threading as _threading
_plock = progress_lock if progress_lock is not None else _threading.Lock()
def _load_prog():
if load_progress_raw is not None:
return load_progress_raw()
pf = SAVETV_DIR / ".download_progress.json"
import json as _j
return _j.loads(pf.read_text()) if pf.exists() else {}
def _save_prog(prog):
if save_progress_raw is not None:
save_progress_raw(prog)
else:
import json as _j
(SAVETV_DIR / ".download_progress.json").write_text(_j.dumps(prog, ensure_ascii=False, indent=2))
@app.route("/files/<path:filename>")
def serve_file(filename):
return send_from_directory(str(SAVETV_DIR), filename, as_attachment=True)
@app.route("/api/delete", methods=["POST"])
def api_delete():
data = request.get_json()
filename = data.get("filename", "")
if not filename or ".." in filename or "/" in filename:
return jsonify({"ok": False, "error": "Ungueltig"}), 400
target = SAVETV_DIR / filename
if not target.exists():
return jsonify({"ok": False, "error": "Nicht gefunden"}), 404
try:
target.unlink()
return jsonify({"ok": True, "deleted": filename})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/jellyfin_library")
def api_jellyfin_library():
try:
result = _jellyfin_fetch_library()
return jsonify(
{
"ok": True,
"normalized_titles": result["normalized_titles"],
"count": result["count"],
"cached": result.get("cached", False),
}
)
except Exception as e:
return (
jsonify(
{
"ok": False,
"error": str(e),
"normalized_titles": [],
"count": 0,
"cached": False,
}
),
500,
)
@app.route("/downloads")
def downloads_page():
files = []
for fp in SAVETV_DIR.iterdir():
if fp.suffix == ".mp4":
st = fp.stat()
size_mb = round(st.st_size / 1024 / 1024, 1)
mtime = st.st_mtime
files.append((fp.name, size_mb, mtime))
files.sort(key=lambda x: x[2], reverse=True)
total_gb = round(sum(s for _, s, _ in files) / 1024, 2)
nav = '<div style="display:flex;gap:16px;margin-bottom:24px"><a href="/" style="color:#999;text-decoration:none;font-size:15px">&larr; Archiv</a><a href="/downloads" style="color:#4caf92;text-decoration:none;font-size:15px">&#128193; Downloads</a><a href="/status" style="color:#5a7fa8;text-decoration:none;font-size:15px">&#9881;&#65039; Status</a></div>'
rows = ""
from datetime import datetime as _dt
for name, size, mtime in files:
clean = name.rsplit(".", 1)[0]
esc = _html.escape(name, quote=True)
date_str = _dt.fromtimestamp(mtime).strftime("%d.%m.%Y")
_b = SAVETV_DIRECT_BASE.split("://", 1)
_auth = _urlquote(SAVETV_DIRECT_USER) + ":" + _urlquote(SAVETV_DIRECT_PASS) + "@"
href_direct = _b[0] + "://" + _auth + _b[1].lstrip("/").rstrip("/") + "/files/" + _urlquote(name)
href_tunnel = SAVETV_TUNNEL_BASE.rstrip("/") + "/files/" + _urlquote(name)
rows += (
'<tr data-name="' + _html.escape(clean.lower(), quote=True) + '" '
'data-date="' + str(int(mtime)) + '" '
'data-size="' + str(size) + '">'
'<td style="padding:16px 20px;font-size:18px;font-weight:500">' + clean + '</td>'
'<td style="padding:16px 20px;color:#666;font-size:14px;white-space:nowrap">' + date_str + '</td>'
'<td style="padding:16px 20px;color:#888;font-size:15px;white-space:nowrap">' + str(size) + ' MB</td>'
'<td style="padding:16px 20px;white-space:nowrap">'
'<a href="' + href_direct + '" download="' + esc + '" '
'style="background:#4caf92;color:#000;font-weight:700;padding:8px 20px;border-radius:5px;text-decoration:none;font-size:15px">'
'&#11015; Download</a> '
'<a href="' + href_tunnel + '" title="Cloudflare-Tunnel" '
'style="color:#5a7fa8;font-size:13px;font-weight:600;margin-left:8px;text-decoration:none">CF</a> '
'<button data-file="' + esc + '" onclick="delFile(this)" '
'style="background:#ff3d3d;color:#fff;font-weight:700;padding:8px 16px;border-radius:5px;border:none;cursor:pointer;font-size:15px;margin-left:10px">'
'&#128465; L\u00f6schen</button>'
'</td></tr>'
)
return (
'<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Downloads</title>'
'<style>'
'*{box-sizing:border-box}'
'body{background:#111118;color:#e8e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0;padding:32px 40px;font-size:16px;line-height:1.5}'
'h1{font-size:28px;margin-bottom:8px;font-weight:700;letter-spacing:-0.5px}'
'.sub{color:#777;font-size:15px;margin-bottom:16px}'
'.sortbar{display:flex;align-items:center;gap:8px;margin-bottom:24px;flex-wrap:wrap}'
'.sortbar span{color:#666;font-size:14px;margin-right:4px}'
'.sbtn{background:#1e1e2e;color:#999;border:1px solid #2a2a3a;padding:7px 16px;border-radius:6px;cursor:pointer;font-size:14px;font-weight:600;transition:all .15s}'
'.sbtn:hover{background:#252538;color:#ccc;border-color:#3a3a5a}'
'.sbtn.active{background:#1a2a3a;color:#4caf92;border-color:#4caf92}'
'.sbtn .arrow{margin-left:5px;font-size:11px}'
'table{border-collapse:collapse;width:100%;max-width:1100px}'
'tr:hover{background:#1a1a24}'
'tr{border-bottom:1px solid #2a2a3a}'
'</style></head><body>'
+ nav +
'<h1>&#128193; Gespeicherte Filme</h1>'
'<div class="sub">' + str(len(files)) + ' Dateien &middot; ' + str(total_gb) + ' GB</div>'
'<div class="sub" style="margin-top:16px;padding:12px 16px;background:#1a1a24;border:1px solid #2a2a3a;border-radius:8px;font-size:14px;max-width:1100px;line-height:1.55">'
'Direkt vom Hetzner (ohne Cloudflare). '
'Credentials sind im Link eingebettet &ndash; Browser-Auth-Dialog sollte nicht erscheinen. '
'Fallback-Link <span style="color:#888">CF</span> pro Zeile nutzt den Cloudflare-Tunnel '
'(<code style="color:#e0e0e8">' + _html.escape(SAVETV_TUNNEL_BASE, quote=True) + '</code>).'
'</div>'
'<div class="sortbar"><span>Sortieren:</span>'
'<button class="sbtn active" data-sort="date" data-dir="desc" onclick="sortBy(this)">Datum <span class="arrow">&#9660;</span></button>'
'<button class="sbtn" data-sort="name" data-dir="asc" onclick="sortBy(this)">Name <span class="arrow">&#9650;</span></button>'
'<button class="sbtn" data-sort="size" data-dir="desc" onclick="sortBy(this)">Gr&ouml;&szlig;e <span class="arrow">&#9660;</span></button>'
'</div>'
'<table id="ftable"><tbody id="fbody">' + rows + '</tbody></table>'
'<script>'
'function sortBy(btn){'
' var key=btn.getAttribute("data-sort");'
' var dir=btn.getAttribute("data-dir");'
' var prev=document.querySelector(".sbtn.active");'
' if(prev&&prev===btn){dir=dir==="asc"?"desc":"asc";btn.setAttribute("data-dir",dir)}'
' else{if(prev)prev.classList.remove("active");btn.classList.add("active")}'
' btn.querySelector(".arrow").innerHTML=dir==="asc"?"&#9650;":"&#9660;";'
' var tb=document.getElementById("fbody");'
' var rows=Array.from(tb.querySelectorAll("tr"));'
' rows.sort(function(a,b){'
' var av,bv;'
' if(key==="date"){av=parseInt(a.getAttribute("data-date"));bv=parseInt(b.getAttribute("data-date"))}'
' else if(key==="size"){av=parseFloat(a.getAttribute("data-size"));bv=parseFloat(b.getAttribute("data-size"))}'
' else{av=a.getAttribute("data-name");bv=b.getAttribute("data-name");return dir==="asc"?av.localeCompare(bv):bv.localeCompare(av)}'
' return dir==="asc"?av-bv:bv-av'
' });'
' rows.forEach(function(r){tb.appendChild(r)})'
'}'
'function delFile(btn){var n=btn.getAttribute("data-file");if(!confirm("Wirklich loeschen? "+n))return;fetch("/api/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:n})}).then(function(r){return r.json()}).then(function(d){if(d.ok){btn.closest("tr").remove()}else{alert(d.error)}})}'
'</script></body></html>'
)
FILMINFO_CACHE = Path("/mnt/savetv/.filminfo_cache.json")
BOGUS_GENRES = {"Stummfilm", "Tonfilm", "Farbfilm", "Schwarzweissfilm",
"Langfilm", "Kurzfilm", "Independentfilm"}
def _load_filminfo_cache():
if FILMINFO_CACHE.exists():
try:
import json as _json
return _json.loads(FILMINFO_CACHE.read_text())
except Exception:
pass
return {}
def _save_filminfo_cache(cache):
import json as _json
FILMINFO_CACHE.write_text(_json.dumps(cache, ensure_ascii=False, indent=1))
def _wikidata_lookup(title):
"""Lookup year/genre/country for a film title via Wikidata."""
import requests as _rq
import re
search_title = re.sub(r"\s*[-\u2013\u2014]\s*.+$", "", title).strip()
result = {"year": "", "genres": [], "countries": []}
def _parse_bindings(bindings):
year = ""
genres = set()
countries = set()
for b in bindings:
if not year and b.get("year", {}).get("value"):
year = b["year"]["value"]
if b.get("genreLabel", {}).get("value"):
genres.add(b["genreLabel"]["value"])
if b.get("countryLabel", {}).get("value"):
countries.add(b["countryLabel"]["value"])
return year, sorted(genres)[:3], sorted(countries)[:2]
for lang in ["de", "en"]:
sparql = ('SELECT ?year ?genreLabel ?countryLabel WHERE {{ '
'?film wdt:P31 wd:Q11424 . '
'?film rdfs:label "{t}"@{l} . '
'OPTIONAL {{ ?film wdt:P577 ?date }} '
'OPTIONAL {{ ?film wdt:P136 ?genre }} '
'OPTIONAL {{ ?film wdt:P495 ?country }} '
'BIND(YEAR(?date) AS ?year) '
'SERVICE wikibase:label {{ bd:serviceParam wikibase:language "de,en" }} '
'}} LIMIT 20').format(t=search_title.replace('"', '\\"'), l=lang)
try:
r = _rq.get("https://query.wikidata.org/sparql",
params={"query": sparql, "format": "json"},
headers={"User-Agent": "SaveTV/1.0"}, timeout=8)
bindings = r.json().get("results", {}).get("bindings", [])
if bindings:
y, g, c = _parse_bindings(bindings)
return {"year": y, "genres": [x for x in g if x not in BOGUS_GENRES], "countries": c}
except Exception:
pass
# Fallback: Wikidata search
try:
sr = _rq.get("https://www.wikidata.org/w/api.php",
params={"action": "wbsearchentities", "search": search_title,
"language": "de", "type": "item", "limit": "3", "format": "json"},
headers={"User-Agent": "SaveTV/1.0"}, timeout=8)
for item in sr.json().get("search", []):
qid = item.get("id", "")
sparql_q = ('SELECT ?year ?genreLabel ?countryLabel WHERE {{ '
'BIND(wd:{qid} AS ?film) '
'?film wdt:P31 wd:Q11424 . '
'OPTIONAL {{ ?film wdt:P577 ?date }} '
'OPTIONAL {{ ?film wdt:P136 ?genre }} '
'OPTIONAL {{ ?film wdt:P495 ?country }} '
'BIND(YEAR(?date) AS ?year) '
'SERVICE wikibase:label {{ bd:serviceParam wikibase:language "de,en" }} '
'}} LIMIT 20').format(qid=qid)
r2 = _rq.get("https://query.wikidata.org/sparql",
params={"query": sparql_q, "format": "json"},
headers={"User-Agent": "SaveTV/1.0"}, timeout=8)
bindings = r2.json().get("results", {}).get("bindings", [])
if bindings:
y, g, c = _parse_bindings(bindings)
return {"year": y, "genres": [x for x in g if x not in BOGUS_GENRES], "countries": c}
except Exception:
pass
return result
@app.route("/api/filminfo")
def api_filminfo():
title = request.args.get("title", "").strip()
if not title:
return jsonify({"error": "title missing"}), 400
cache = _load_filminfo_cache()
if title in cache:
return jsonify(cache[title])
info = _wikidata_lookup(title)
cache[title] = info
_save_filminfo_cache(cache)
return jsonify(info)
@app.route("/api/filminfo_batch", methods=["POST"])
def api_filminfo_batch():
data = request.get_json()
titles = data.get("titles", [])
cache = _load_filminfo_cache()
results = {}
missing = []
for t in titles:
if t in cache:
results[t] = cache[t]
else:
missing.append(t)
for t in missing:
info = _wikidata_lookup(t)
cache[t] = info
results[t] = info
if missing:
_save_filminfo_cache(cache)
return jsonify(results)
@app.route("/api/download_progress")
def api_download_progress():
import json as _json
import subprocess as _sp
progress_file = SAVETV_DIR / ".download_progress.json"
if not progress_file.exists():
return jsonify({})
try:
progress = _json.loads(progress_file.read_text())
except Exception:
return jsonify({})
dl_log_file = SAVETV_DIR / ".download_log.json"
try:
dl_log = _json.loads(dl_log_file.read_text()) if dl_log_file.exists() else {}
except Exception:
dl_log = {}
# Stale "running"-Eintraege bereinigen: im Log als running, aber kein Progress-Eintrag
# und kein wget-Prozess → Download ist gescheitert, Eintrag entfernen
stale = []
for tid, status in list(dl_log.items()):
if status != "running":
continue
if tid in progress:
continue
# Pruefen ob wget fuer diesen TID noch laeuft
try:
chk = _sp.run(["pgrep", "-af", f"_{tid}.mp4"],
capture_output=True, text=True, timeout=3)
if "wget" in chk.stdout:
continue
except Exception:
pass
stale.append(tid)
if stale:
for tid in stale:
dl_log.pop(tid, None)
dl_log_file.write_text(_json.dumps(dl_log, ensure_ascii=False, indent=2))
result = {}
completed = []
for tid, info in list(progress.items()):
fp = SAVETV_DIR / info["filename"]
current = fp.stat().st_size if fp.exists() else 0
expected = info.get("expected_bytes", 0)
wget_running = False
try:
ps = _sp.run(["pgrep", "-af", info["filename"]],
capture_output=True, text=True, timeout=3)
wget_running = "wget" in ps.stdout
except Exception:
pass
done = False
if expected > 0 and current >= expected:
done = True
elif not wget_running and current > 100_000:
done = True
percent = round(current / expected * 100, 1) if expected > 0 else 0
result[tid] = {
"current_bytes": current,
"expected_bytes": expected,
"percent": min(percent, 100),
"current_mb": round(current / 1024 / 1024, 1),
"expected_mb": round(expected / 1024 / 1024, 1),
"done": done,
}
if done:
completed.append(tid)
if completed:
for tid in completed:
info = progress.get(tid, {})
raw_filename = info.get("filename", "")
# Rename: "Titel_ID.mp4" -> "Titel (Jahr).mp4"
if raw_filename:
_rename_to_jellyfin(raw_filename, tid)
# Auto-Delete aus Save.TV Archiv
try:
import sys as _sys
_sys.path.insert(0, '/opt/homelab-ai-bot')
from tools import savetv as _savetv
ok, err = _savetv._delete_telecast(int(tid))
if ok:
import logging as _log
_log.getLogger("savetv").info("Archiv-Eintrag %s nach Download gelöscht", tid)
except Exception as _e:
import logging as _log
_log.getLogger("savetv").warning("Archiv-Delete TID %s fehlgeschlagen: %s", tid, _e)
dl_log[tid] = "done"
with _plock:
cur = _load_prog()
for tid in completed:
cur.pop(tid, None)
_save_prog(cur)
dl_log_file.write_text(_json.dumps(dl_log, ensure_ascii=False, indent=2))
return jsonify(result)
def _find_cache_match(cache, clean_title):
"""Sucht den besten Cache-Eintrag: exakt, dann normalisiert (Sonderzeichen-tolerant)."""
if clean_title in cache and cache[clean_title].get("year"):
return cache[clean_title]
import re as _re2
def _norm(s):
return _re2.sub(r'\s+', ' ', _re2.sub(r'[^\w\s]', ' ', s)).strip().lower()
norm = _norm(clean_title)
for key, val in cache.items():
if not val.get("year"):
continue
if _norm(key) == norm:
return val
return None
def _rename_to_jellyfin(raw_filename, tid):
"""Benennt fertig gedownloadete Datei von 'Titel_ID.mp4' zu 'Titel (Jahr).mp4' um."""
import re as _re
src = SAVETV_DIR / raw_filename
if not src.exists():
return
m = _re.match(r'^(.+)_(\d{6,9})\.mp4$', raw_filename)
if not m:
return
raw_title_part = m.group(1)
clean_title = raw_title_part.replace('_-_', ' - ').replace('_', ' ').strip()
cache = _load_filminfo_cache()
matched = _find_cache_match(cache, clean_title)
if matched:
year = matched.get("year", "")
else:
if clean_title not in cache:
cache[clean_title] = _wikidata_lookup(clean_title)
_save_filminfo_cache(cache)
year = cache[clean_title].get("year", "")
# Zieldateiname bauen
safe_title = _re.sub(r'[\\/:*?"<>|]', '', clean_title).strip()
if year:
dest_name = f"{safe_title} ({year}).mp4"
else:
dest_name = f"{safe_title}.mp4"
dest = SAVETV_DIR / dest_name
# Nicht überschreiben falls schon vorhanden
if dest.exists():
# Alten Raw-File löschen
try:
src.unlink()
except Exception:
pass
return
try:
src.rename(dest)
# Auch Progress-Info aktualisieren
import logging
logging.getLogger("savetv").info(f"Umbenannt: {raw_filename} -> {dest_name}")
except Exception as e:
import logging
logging.getLogger("savetv").warning(f"Rename fehlgeschlagen {raw_filename}: {e}")
@app.route("/health")
def health():
from tools import savetv
checks = {}
ok_count = 0
total = 0
total += 1
try:
s = savetv._get_session()
checks["savetv_login"] = {"ok": s is not None, "detail": "Session aktiv" if s else "Login fehlgeschlagen"}
if s: ok_count += 1
except Exception as e:
checks["savetv_login"] = {"ok": False, "detail": str(e)}
total += 1
try:
t0 = _time.time()
entries = savetv._get_archive(count=5)
dur = round(_time.time() - t0, 2)
checks["savetv_archive"] = {"ok": len(entries) > 0, "detail": f"{len(entries)} Eintraege in {dur}s"}
if entries: ok_count += 1
except Exception as e:
checks["savetv_archive"] = {"ok": False, "detail": str(e)}
total += 1
try:
usage = shutil.disk_usage("/mnt/savetv")
free_gb = round(usage.free / 1024**3, 1)
mp4s = list(SAVETV_DIR.glob("*.mp4"))
total_size = round(sum(f.stat().st_size for f in mp4s) / 1024**3, 2)
checks["storage"] = {"ok": free_gb > 10, "detail": f"{len(mp4s)} Filme, {total_size} GB belegt, {free_gb} GB frei"}
if free_gb > 10: ok_count += 1
except Exception as e:
checks["storage"] = {"ok": False, "detail": str(e)}
total += 1
try:
import requests as _rq
t0 = _time.time()
sparql = 'SELECT ?year WHERE { ?f wdt:P31 wd:Q11424 . ?f rdfs:label "The Revenant"@en . ?f wdt:P577 ?date . BIND(YEAR(?date) AS ?year) } LIMIT 1'
wr = _rq.get("https://query.wikidata.org/sparql", params={"query": sparql, "format": "json"}, headers={"User-Agent": "SaveTV/1.0"}, timeout=10)
bindings = wr.json().get("results", {}).get("bindings", [])
year = bindings[0]["year"]["value"] if bindings else ""
dur = round(_time.time() - t0, 2)
checks["wikidata"] = {"ok": bool(year), "detail": f"The Revenant -> {year} ({dur}s)"}
if year: ok_count += 1
except Exception as e:
checks["wikidata"] = {"ok": False, "detail": str(e)}
total += 1
try:
import requests as _rq
r = _rq.get("http://localhost:8766/", timeout=3)
checks["nginx"] = {"ok": r.status_code == 200, "detail": f"Port 8766 -> {r.status_code}"}
if r.status_code == 200: ok_count += 1
except Exception as e:
checks["nginx"] = {"ok": False, "detail": str(e)}
total += 1
try:
import subprocess as _sp
result = _sp.run(["systemctl", "is-active", "cloudflared"], capture_output=True, text=True, timeout=5)
active = result.stdout.strip() == "active"
checks["cloudflare_tunnel"] = {"ok": active, "detail": result.stdout.strip()}
if active: ok_count += 1
except Exception as e:
checks["cloudflare_tunnel"] = {"ok": False, "detail": str(e)}
total += 1
checks["flask_web"] = {"ok": True, "detail": "Port 8765 aktiv"}
ok_count += 1
return jsonify({"healthy": ok_count == total, "ok": ok_count, "total": total, "checks": checks})
@app.route("/status")
def status_page():
nav = '<div style="display:flex;gap:16px;margin-bottom:24px"><a href="/" style="color:#999;text-decoration:none;font-size:15px">&larr; Archiv</a><a href="/downloads" style="color:#4caf92;text-decoration:none;font-size:15px">&#128193; Downloads</a><a href="/status" style="color:#5a7fa8;text-decoration:none;font-size:15px">&#9881;&#65039; Status</a></div>'
return '''<!DOCTYPE html>
<html lang="de"><head><meta charset="UTF-8"><title>Save.TV Status</title>
<style>
*{box-sizing:border-box}
body{background:#111118;color:#e8e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0;padding:32px 40px;font-size:16px;line-height:1.6}
h1{font-size:28px;margin-bottom:4px;font-weight:700}
.sub{color:#777;font-size:15px;margin-bottom:32px}
.checks{max-width:800px}
.check{display:flex;align-items:center;gap:16px;padding:18px 20px;border-bottom:1px solid #2a2a3a;transition:background .15s}
.check:hover{background:#1a1a24}
.icon{font-size:24px;width:36px;text-align:center}
.name{font-weight:600;font-size:17px;min-width:220px}
.detail{color:#8888a8;font-size:14px}
.summary{margin:28px 0;padding:20px 24px;border-radius:8px;font-size:18px;font-weight:600}
.summary.ok{background:#122a1a;border:1px solid #4caf92;color:#4caf92}
.summary.warn{background:#2a1a0a;border:1px solid #ffa726;color:#ffa726}
.summary.fail{background:#2a0d0d;border:1px solid #ff3d3d;color:#ff3d3d}
.loading{color:#777;font-size:17px;padding:40px 0}
.features{max-width:800px;margin-top:40px}
.features h2{font-size:20px;margin-bottom:16px;font-weight:700}
.feat{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid #1e1e2e;font-size:15px}
.feat .dot{width:8px;height:8px;border-radius:50%;background:#4caf92}
.feat .label{color:#ccc}
.feat .ver{color:#666;margin-left:auto;font-size:13px}
</style>
</head><body>
''' + nav + '''
<h1>&#9881;&#65039; System Status</h1>
<div class="sub">Save.TV Download-System &mdash; Live-Checks</div>
<div id="result" class="loading">Pr&uuml;fe Systeme...</div>
<div class="checks" id="checks"></div>
<div class="features">
<h2>&#128221; Funktions&uuml;bersicht</h2>
<div class="feat"><div class="dot"></div><div class="label">Save.TV EPG-Scanner (t&auml;glich 14:00, Auto-Aufnahme)</div><div class="ver">17.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">Archiv-Bewertung: Kino-Highlights vs. TV-Filme</div><div class="ver">17.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">Film-Download auf Hetzner (HD werbefrei)</div><div class="ver">17.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">Web-Dashboard: Archiv durchsuchen + Download starten</div><div class="ver">17.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">Jellyfin-Naming: Film (Jahr).mp4 via Wikidata</div><div class="ver">20.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">Download-Seite mit direkten HTTP-Downloads</div><div class="ver">20.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">L&ouml;schfunktion (Web-UI)</div><div class="ver">20.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">Cloudflare Tunnel (savetv.orbitalo.net)</div><div class="ver">20.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">Direkter Download (138.201.84.95:9443, Basic Auth)</div><div class="ver">20.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">nginx Reverse Proxy + Static File Serving</div><div class="ver">20.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">Telegram Bot Integration (Aufnahme, Status, Tipps)</div><div class="ver">17.03.2026</div></div>
<div class="feat"><div class="dot"></div><div class="label">Status-Seite + Health-Endpoint</div><div class="ver">20.03.2026</div></div>
</div>
<script>
fetch("/health").then(r=>r.json()).then(d=>{
var s=document.getElementById("result");
if(d.healthy){s.className="summary ok";s.textContent="\\u2705 Alles OK \\u2014 "+d.ok+"/"+d.total+" Checks bestanden"}
else if(d.ok>d.total/2){s.className="summary warn";s.textContent="\\u26a0\\ufe0f "+d.ok+"/"+d.total+" Checks bestanden"}
else{s.className="summary fail";s.textContent="\\u274c "+d.ok+"/"+d.total+" Checks bestanden"}
var c=document.getElementById("checks");
var html="";
var order=["savetv_login","savetv_archive","storage","wikidata","nginx","cloudflare_tunnel","flask_web"];
var names={"savetv_login":"Save.TV Login","savetv_archive":"Archiv-Zugriff","storage":"Speicher","wikidata":"Wikidata Lookup","nginx":"nginx Proxy","cloudflare_tunnel":"Cloudflare Tunnel","flask_web":"Flask Web-UI"};
order.forEach(function(k){
var v=d.checks[k];if(!v)return;
html+="<div class=\\"check\\"><div class=\\"icon\\">"+(v.ok?"\\u2705":"\\u274c")+"</div><div class=\\"name\\">"+names[k]+"</div><div class=\\"detail\\">"+v.detail+"</div></div>";
});
c.innerHTML=html;
});
</script></body></html>'''