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:
parent
902441bbbc
commit
6989b5c07b
2 changed files with 130 additions and 2 deletions
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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>'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue