diff --git a/homelab-ai-bot/savetv_extra_routes.py b/homelab-ai-bot/savetv_extra_routes.py index 293f4c22..f231d50b 100644 --- a/homelab-ai-bot/savetv_extra_routes.py +++ b/homelab-ai-bot/savetv_extra_routes.py @@ -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 = [] diff --git a/homelab-ai-bot/savetv_web.py b/homelab-ai-bot/savetv_web.py index 90def214..9c2998e6 100644 --- a/homelab-ai-bot/savetv_web.py +++ b/homelab-ai-bot/savetv_web.py @@ -160,6 +160,7 @@ HTML = r""" .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""" } .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 = '
' + (sel ? '\u2713' : '') + '
'; 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 ? 'Jellyfin' : ''; + return '
' + statusEl + '
' + esc(f.title) + '
' @@ -602,6 +617,7 @@ function filmCard(f) { + '' + esc(f.station) + '' + (dateLabel ? '' + dateLabel + '' : '') + '' + (f.cinema ? 'Kino' : 'TV') + '' + + jellyfinBadge + '' + daysLabel + '' + '
' + '
' + detailsHtml + '
'