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."""
|
"""Extra Routes fuer savetv_web.py - nicht im Git, lokal in CT 116."""
|
||||||
|
|
||||||
import html as _html
|
import html as _html
|
||||||
|
import json as _json
|
||||||
|
import os as _os
|
||||||
|
import re as _re
|
||||||
import shutil
|
import shutil
|
||||||
import time as _time
|
import time as _time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -9,6 +12,89 @@ from flask import send_from_directory, request, jsonify
|
||||||
|
|
||||||
SAVETV_DIR = Path("/mnt/savetv")
|
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):
|
def register_extra_routes(app, progress_lock=None, load_progress_raw=None, save_progress_raw=None):
|
||||||
import threading as _threading
|
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:
|
except Exception as e:
|
||||||
return jsonify({"ok": False, "error": str(e)}), 500
|
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")
|
@app.route("/downloads")
|
||||||
def downloads_page():
|
def downloads_page():
|
||||||
files = []
|
files = []
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ HTML = r"""<!DOCTYPE html>
|
||||||
.badge-urgent { background: var(--urgent); color: #fff; }
|
.badge-urgent { background: var(--urgent); color: #fff; }
|
||||||
.badge-kino { background: var(--kino); color: #000; }
|
.badge-kino { background: var(--kino); color: #000; }
|
||||||
.badge-tv { background: var(--tv); color: #fff; }
|
.badge-tv { background: var(--tv); color: #fff; }
|
||||||
|
.badge-jellyfin { background: #6c5ce7; color: #fff; font-size: 12px; }
|
||||||
.film-grid {
|
.film-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
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.downloaded { border-color: var(--kino); opacity: .55; }
|
||||||
.film-card.downloading { border-color: #ffa726; }
|
.film-card.downloading { border-color: #ffa726; }
|
||||||
|
.film-card.in-jellyfin { border-color: #5b4a9a; }
|
||||||
.film-title {
|
.film-title {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
|
|
@ -437,6 +439,7 @@ let infoQueue = [];
|
||||||
let infoLoading = false;
|
let infoLoading = false;
|
||||||
let dlProgress = {};
|
let dlProgress = {};
|
||||||
let polling = false;
|
let polling = false;
|
||||||
|
let jellyfinSet = null;
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
var d = document.createElement('div');
|
var d = document.createElement('div');
|
||||||
|
|
@ -502,8 +505,16 @@ async function loadFilms() {
|
||||||
document.getElementById('content').style.display = 'block';
|
document.getElementById('content').style.display = 'block';
|
||||||
var storedCount = 0;
|
var storedCount = 0;
|
||||||
allFilms.forEach(function(f) { if (storedSet.has(normTitle(f.title))) storedCount++; });
|
allFilms.forEach(function(f) { if (storedSet.has(normTitle(f.title))) storedCount++; });
|
||||||
document.getElementById('header-stats').textContent =
|
var statsLine = data.total + ' Aufnahmen \u00b7 ' + data.kino + ' Kino \u00b7 ' + data.urgent + ' dringend \u00b7 ' + storedCount + ' gespeichert';
|
||||||
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();
|
renderFilms();
|
||||||
var hasRunning = Object.values(downloads).some(function(v) { return v === 'running'; });
|
var hasRunning = Object.values(downloads).some(function(v) { return v === 'running'; });
|
||||||
if (hasRunning) startPolling();
|
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 daysLabel = f.days_left === 0 ? 'heute' : f.days_left === 1 ? '1 Tag' : f.days_left + ' Tage';
|
||||||
|
|
||||||
var isStored = storedSet.has(normTitle(f.title));
|
var isStored = storedSet.has(normTitle(f.title));
|
||||||
|
var inJellyfin = jellyfinSet && jellyfinSet.has(normTitle(f.title));
|
||||||
|
|
||||||
var cardClass = 'film-card';
|
var cardClass = 'film-card';
|
||||||
if (sel) cardClass += ' selected';
|
if (sel) cardClass += ' selected';
|
||||||
if (dlState === 'done' || isStored) cardClass += ' downloaded';
|
if (dlState === 'done' || isStored) cardClass += ' downloaded';
|
||||||
if (dlState === 'running') cardClass += ' downloading';
|
if (dlState === 'running') cardClass += ' downloading';
|
||||||
|
if (inJellyfin) cardClass += ' in-jellyfin';
|
||||||
|
|
||||||
var statusEl = '<div class="checkbox-indicator">' + (sel ? '\u2713' : '') + '</div>';
|
var statusEl = '<div class="checkbox-indicator">' + (sel ? '\u2713' : '') + '</div>';
|
||||||
var extraLine = '';
|
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'});
|
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 + '">'
|
return '<div class="' + cardClass + '" onclick="toggleFilm(' + f.tid + ')" data-tid="' + f.tid + '">'
|
||||||
+ statusEl
|
+ statusEl
|
||||||
+ '<div class="film-title">' + esc(f.title) + '</div>'
|
+ '<div class="film-title">' + esc(f.title) + '</div>'
|
||||||
|
|
@ -602,6 +617,7 @@ function filmCard(f) {
|
||||||
+ '<span>' + esc(f.station) + '</span>'
|
+ '<span>' + esc(f.station) + '</span>'
|
||||||
+ (dateLabel ? '<span style="color:#8888a8">' + dateLabel + '</span>' : '')
|
+ (dateLabel ? '<span style="color:#8888a8">' + dateLabel + '</span>' : '')
|
||||||
+ '<span class="badge ' + (f.cinema ? 'badge-kino' : 'badge-tv') + '">' + (f.cinema ? 'Kino' : 'TV') + '</span>'
|
+ '<span class="badge ' + (f.cinema ? 'badge-kino' : 'badge-tv') + '">' + (f.cinema ? 'Kino' : 'TV') + '</span>'
|
||||||
|
+ jellyfinBadge
|
||||||
+ '<span class="days-badge ' + daysClass + '">' + daysLabel + '</span>'
|
+ '<span class="days-badge ' + daysClass + '">' + daysLabel + '</span>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="' + detailsClass + '" id="details-' + f.tid + '">' + detailsHtml + '</div>'
|
+ '<div class="' + detailsClass + '" id="details-' + f.tid + '">' + detailsHtml + '</div>'
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue