feat(savetv): Jellyfin-Abgleich im Archiv

- API /api/jellyfin_library: Jellyfin-Filme via Tailscale, 1h Cache
- Archiv-Karten: Badge Jellyfin wenn Titel in Mediathek (normalisierter Vergleich)
- Entfernt: /opt/savetv_extra_routes.py Doppelung (nur noch Repo-Modul)
This commit is contained in:
Homelab Cursor 2026-03-28 17:28:37 +01:00
parent 902441bbbc
commit 6989b5c07b
2 changed files with 130 additions and 2 deletions

View file

@ -1,6 +1,9 @@
"""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
@ -9,6 +12,89 @@ 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")
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
@ -47,6 +133,32 @@ def register_extra_routes(app, progress_lock=None, load_progress_raw=None, save_
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 = []

View file

@ -160,6 +160,7 @@ HTML = r"""<!DOCTYPE html>
.badge-urgent { background: var(--urgent); color: #fff; }
.badge-kino { background: var(--kino); color: #000; }
.badge-tv { background: var(--tv); color: #fff; }
.badge-jellyfin { background: #6c5ce7; color: #fff; font-size: 12px; }
.film-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
@ -182,6 +183,7 @@ HTML = r"""<!DOCTYPE html>
}
.film-card.downloaded { border-color: var(--kino); opacity: .55; }
.film-card.downloading { border-color: #ffa726; }
.film-card.in-jellyfin { border-color: #5b4a9a; }
.film-title {
font-weight: 700;
font-size: 19px;
@ -437,6 +439,7 @@ let infoQueue = [];
let infoLoading = false;
let dlProgress = {};
let polling = false;
let jellyfinSet = null;
function esc(s) {
var d = document.createElement('div');
@ -502,8 +505,16 @@ async function loadFilms() {
document.getElementById('content').style.display = 'block';
var storedCount = 0;
allFilms.forEach(function(f) { if (storedSet.has(normTitle(f.title))) storedCount++; });
document.getElementById('header-stats').textContent =
data.total + ' Aufnahmen \u00b7 ' + data.kino + ' Kino \u00b7 ' + data.urgent + ' dringend \u00b7 ' + storedCount + ' gespeichert';
var statsLine = data.total + ' Aufnahmen \u00b7 ' + data.kino + ' Kino \u00b7 ' + data.urgent + ' dringend \u00b7 ' + storedCount + ' gespeichert';
try {
var jr = await fetch('/api/jellyfin_library');
var jdata = await jr.json();
if (jdata.ok && Array.isArray(jdata.normalized_titles)) {
jellyfinSet = new Set(jdata.normalized_titles);
statsLine += ' \u00b7 Jellyfin ' + (jdata.count || 0) + ' Filme';
}
} catch (e) {}
document.getElementById('header-stats').textContent = statsLine;
renderFilms();
var hasRunning = Object.values(downloads).some(function(v) { return v === 'running'; });
if (hasRunning) startPolling();
@ -556,11 +567,13 @@ function filmCard(f) {
var daysLabel = f.days_left === 0 ? 'heute' : f.days_left === 1 ? '1 Tag' : f.days_left + ' Tage';
var isStored = storedSet.has(normTitle(f.title));
var inJellyfin = jellyfinSet && jellyfinSet.has(normTitle(f.title));
var cardClass = 'film-card';
if (sel) cardClass += ' selected';
if (dlState === 'done' || isStored) cardClass += ' downloaded';
if (dlState === 'running') cardClass += ' downloading';
if (inJellyfin) cardClass += ' in-jellyfin';
var statusEl = '<div class="checkbox-indicator">' + (sel ? '\u2713' : '') + '</div>';
var extraLine = '';
@ -595,6 +608,8 @@ function filmCard(f) {
if (!isNaN(d)) dateLabel = d.toLocaleDateString('de-DE', {day:'2-digit', month:'2-digit', year:'numeric'});
}
var jellyfinBadge = inJellyfin ? '<span class="badge badge-jellyfin" title="Bereits in der Jellyfin-Mediathek">Jellyfin</span>' : '';
return '<div class="' + cardClass + '" onclick="toggleFilm(' + f.tid + ')" data-tid="' + f.tid + '">'
+ statusEl
+ '<div class="film-title">' + esc(f.title) + '</div>'
@ -602,6 +617,7 @@ function filmCard(f) {
+ '<span>' + esc(f.station) + '</span>'
+ (dateLabel ? '<span style="color:#8888a8">' + dateLabel + '</span>' : '')
+ '<span class="badge ' + (f.cinema ? 'badge-kino' : 'badge-tv') + '">' + (f.cinema ? 'Kino' : 'TV') + '</span>'
+ jellyfinBadge
+ '<span class="days-badge ' + daysClass + '">' + daysLabel + '</span>'
+ '</div>'
+ '<div class="' + detailsClass + '" id="details-' + f.tid + '">' + detailsHtml + '</div>'