feat: Jellyfin-Integration — ARIA steuert die Wiedergabe ueber die API

Statt sich durch die Fire-TV-Oberflaeche zu klicken (fehleranfaellig: ARIA ist am
Titel vorbeigescrollt und auf Home gelandet) spricht ARIA den Jellyfin-Server
direkt an: suchen -> Sitzung des Abspielgeraets finden -> abspielen.

- Diagnostic (Einstellungen > Integrationen): Jellyfin-Abschnitt mit MEHREREN
  Servern (+-Button; eigener, vom Kumpel ...), einer davon Standard. Pro Server
  Name/URL/API-Key + Test-Knopf (prueft /System/Info, meldet Name und Version).
  Gespeichert in /shared/config/jellyfin.json mit Datei-Rechten 0600 (API-Keys).
- Brain: jellyfin_search / jellyfin_devices / jellyfin_play / jellyfin_control.
  Serverwahl per Name oder Standard. 'resume' ist echt implementiert (liest
  PlaybackPositionTicks und setzt startPositionTicks) statt nur zu existieren.
  Fehlertexte sind handlungsleitend: 401 -> API-Key pruefen; kein Geraet -> die
  Jellyfin-App muss offen sein, sonst erst host_app_launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:23:30 +02:00
co-authored by Claude Opus 4.8
parent f1ae18b658
commit 8f9e3e5c79
3 changed files with 501 additions and 0 deletions
+245
View File
@@ -1501,6 +1501,88 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_search",
"description": (
"Sucht Filme/Serien/Folgen auf einem Jellyfin-Server (Medien-Bibliothek). "
"Liefert Titel, Jahr, Typ und die itemId, die Du zum Abspielen brauchst. "
"NIMM DAS statt Dich durch die Fire-TV-Oberflaeche zu klicken — das ist "
"zuverlaessig und schnell. Server werden im Diagnostic unter "
"Integrationen gepflegt; ohne Angabe wird der Standard-Server genutzt."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchbegriff, z.B. 'Asterix Kleopatra'."},
"server": {"type": "string", "description": "Server-Name (optional, sonst Standard)."},
"limit": {"type": "integer", "description": "Max. Treffer (Default 10)."},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_devices",
"description": (
"Zeigt, auf welchen Geraeten gerade eine Jellyfin-App laeuft und damit "
"fernsteuerbar ist (Sitzungen) — z.B. der Fire TV im Schlafzimmer. "
"WICHTIG: Ein Geraet erscheint nur, wenn die Jellyfin-App dort GEOEFFNET "
"ist. Ist sie zu, starte sie zuerst mit host_app_launch (query='Jellyfin') "
"und frage dann erneut."
),
"parameters": {
"type": "object",
"properties": {
"server": {"type": "string", "description": "Server-Name (optional)."},
},
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_play",
"description": (
"Startet die Wiedergabe eines Titels auf einem Jellyfin-Geraet. Ablauf: "
"1. jellyfin_search -> itemId, 2. jellyfin_devices -> Geraet/Sitzung, "
"3. jellyfin_play. Laeuft die Jellyfin-App auf dem Zielgeraet noch nicht, "
"vorher host_app_launch (query='Jellyfin') aufrufen."
),
"parameters": {
"type": "object",
"properties": {
"item_id": {"type": "string", "description": "itemId aus jellyfin_search."},
"device": {"type": "string", "description": "Geraete-/Sitzungsname aus jellyfin_devices (z.B. 'Fire TV')."},
"server": {"type": "string", "description": "Server-Name (optional)."},
"resume": {"type": "boolean", "description": "Wenn moeglich fortsetzen statt von vorn (Default true)."},
},
"required": ["item_id", "device"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_control",
"description": (
"Steuert die laufende Wiedergabe auf einem Jellyfin-Geraet: pause, "
"unpause, stop, nextTrack, previousTrack."
),
"parameters": {
"type": "object",
"properties": {
"device": {"type": "string", "description": "Geraete-/Sitzungsname."},
"command": {"type": "string", "description": "pause | unpause | stop | nextTrack | previousTrack"},
"server": {"type": "string", "description": "Server-Name (optional)."},
},
"required": ["device", "command"],
},
},
},
{
"type": "function",
"function": {
@@ -2749,6 +2831,165 @@ class Agent:
except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
# ── Jellyfin (Medien) ─────────────────────────────────────
_JELLYFIN_CONFIG = "/shared/config/jellyfin.json"
def _jellyfin_server(self, wanted: str = "") -> Optional[dict]:
"""Server aus der Diagnostic-Konfiguration waehlen (Name oder Standard)."""
try:
with open(self._JELLYFIN_CONFIG, "r", encoding="utf-8") as f:
servers = (json.load(f) or {}).get("servers") or []
except Exception:
return None
servers = [s for s in servers if s.get("url")]
if not servers:
return None
w = (wanted or "").strip().lower()
if w:
for s in servers:
if w in (s.get("name") or "").lower() or w in (s.get("url") or "").lower():
return s
for s in servers:
if s.get("isDefault"):
return s
return servers[0]
def _jellyfin_call(self, server: dict, path: str, method: str = "GET",
params: Optional[dict] = None, timeout: float = 15.0):
"""Ruft die Jellyfin-HTTP-API. Gibt (daten, fehlertext)."""
url = server["url"].rstrip("/") + path
if params:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
req = urllib.request.Request(url, method=method, headers={
"Authorization": f'MediaBrowser Token="{server.get("apiKey", "")}"',
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "ignore")
return (json.loads(raw) if raw.strip() else {}), ""
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
return None, ("Jellyfin lehnt den API-Key ab (401/403) — im Diagnostic "
"unter Integrationen pruefen.")
return None, f"Jellyfin-Fehler HTTP {exc.code}"
except Exception as exc:
return None, f"Jellyfin nicht erreichbar: {exc}"
def _jellyfin_find_session(self, server: dict, device: str):
"""Sucht die Sitzung eines Geraets. Gibt (session, fehlertext)."""
sessions, err = self._jellyfin_call(server, "/Sessions")
if err:
return None, err
d = (device or "").strip().lower()
cands = [s for s in (sessions or []) if s.get("SupportsRemoteControl")]
for s in cands:
blob = f"{s.get('DeviceName', '')} {s.get('Client', '')}".lower()
if d and d in blob:
return s, ""
if not d and len(cands) == 1:
return cands[0], ""
names = ", ".join(f"{s.get('DeviceName')} ({s.get('Client')})" for s in cands) or "keine"
return None, (f"Kein steuerbares Jellyfin-Geraet fuer '{device}' gefunden. "
f"Aktuell offen: {names}. Tipp: Die Jellyfin-App muss auf dem "
f"Geraet GEOEFFNET sein — sonst vorher host_app_launch "
f"(query='Jellyfin') aufrufen.")
def _dispatch_jellyfin(self, name: str, arguments: dict) -> str:
server = self._jellyfin_server(arguments.get("server") or "")
if not server:
return ("FEHLER: Kein Jellyfin-Server eingerichtet. Im Diagnostic unter "
"Einstellungen → Integrationen → Jellyfin einen Server mit URL und "
"API-Key anlegen.")
label = server.get("name") or server.get("url")
try:
if name == "jellyfin_search":
q = (arguments.get("query") or "").strip()
if not q:
return "FEHLER: query ist Pflicht."
data, err = self._jellyfin_call(server, "/Items", params={
"searchTerm": q, "Recursive": "true",
"IncludeItemTypes": "Movie,Series,Episode",
"Limit": int(arguments.get("limit") or 10),
"Fields": "ProductionYear,Overview",
})
if err:
return f"FEHLER: {err}"
items = (data or {}).get("Items") or []
if not items:
return f"Keine Treffer fuer '{q}' auf {label}."
lines = []
for it in items:
lines.append(f"- {it.get('Name')} ({it.get('ProductionYear') or '?'}) "
f"[{it.get('Type')}] itemId={it.get('Id')}")
return f"Treffer auf {label}:\n" + "\n".join(lines)
if name == "jellyfin_devices":
sessions, err = self._jellyfin_call(server, "/Sessions")
if err:
return f"FEHLER: {err}"
out = []
for s in (sessions or []):
if not s.get("SupportsRemoteControl"):
continue
now = (s.get("NowPlayingItem") or {}).get("Name")
out.append(f"- {s.get('DeviceName')} ({s.get('Client')})"
+ (f" — laeuft gerade: {now}" if now else ""))
if not out:
return (f"Auf {label} ist gerade kein steuerbares Geraet offen. Die "
f"Jellyfin-App muss auf dem Zielgeraet laufen — starte sie "
f"mit host_app_launch (query='Jellyfin') und frag dann erneut.")
return f"Steuerbare Jellyfin-Geraete auf {label}:\n" + "\n".join(out)
if name == "jellyfin_play":
item_id = (arguments.get("item_id") or "").strip()
device = (arguments.get("device") or "").strip()
if not item_id or not device:
return "FEHLER: item_id und device sind Pflicht."
sess, err = self._jellyfin_find_session(server, device)
if err:
return f"FEHLER: {err}"
play_params = {"itemIds": item_id, "playCommand": "PlayNow"}
note = ""
if arguments.get("resume") is not False:
# Fortsetzen: gespeicherte Position des Titels mitgeben.
info, _e = self._jellyfin_call(server, "/Items",
params={"ids": item_id, "Fields": "UserData"})
items = (info or {}).get("Items") or []
ticks = 0
if items:
ticks = ((items[0].get("UserData") or {}).get("PlaybackPositionTicks") or 0)
if ticks > 0:
play_params["startPositionTicks"] = ticks
note = f" — fortgesetzt bei {int(ticks / 10_000_000 // 60)} min"
_, err = self._jellyfin_call(
server, f"/Sessions/{sess['Id']}/Playing", method="POST",
params=play_params)
if err:
return f"FEHLER: {err}"
return (f"OK — Wiedergabe auf '{sess.get('DeviceName')}' gestartet"
f"{note} (Server {label}).")
if name == "jellyfin_control":
device = (arguments.get("device") or "").strip()
cmd = (arguments.get("command") or "").strip()
allowed = {"pause", "unpause", "stop", "nexttrack", "previoustrack",
"playpause", "seek"}
if cmd.lower() not in allowed:
return f"FEHLER: command '{cmd}' unbekannt ({', '.join(sorted(allowed))})."
sess, err = self._jellyfin_find_session(server, device)
if err:
return f"FEHLER: {err}"
_, err = self._jellyfin_call(
server, f"/Sessions/{sess['Id']}/Playing/{cmd}", method="POST")
if err:
return f"FEHLER: {err}"
return f"OK — '{cmd}' an '{sess.get('DeviceName')}' gesendet."
return f"FEHLER: unbekanntes Jellyfin-Tool {name}"
except Exception as exc:
return f"FEHLER: Jellyfin-Aufruf fehlgeschlagen: {exc}"
def _dispatch_host(self, name: str, arguments: dict) -> str:
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht
@@ -3737,6 +3978,10 @@ class Agent:
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name in ("jellyfin_search", "jellyfin_devices", "jellyfin_play",
"jellyfin_control"):
return self._dispatch_jellyfin(name, arguments)
if name in ("host_list", "host_exec", "host_read", "host_write",
"host_info", "host_screenshot", "host_ui_dump",
"host_ui_tap", "host_ui_text", "host_ui_swipe",
+142
View File
@@ -961,6 +961,33 @@
</div>
</div>
<!-- Jellyfin (Medien) -->
<div class="settings-section">
<h2>Jellyfin (Filme &amp; Serien)</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Damit steuert ARIA die Wiedergabe direkt über die Jellyfin-Schnittstelle statt
sich durch die Fire-TV-Oberfläche zu klicken: Film suchen → Abspielgerät finden →
starten. Mehrere Server möglich (eigener, vom Kumpel …) — einer davon ist der
Standard, wenn ARIA keinen nennt.
<br>API-Key: Jellyfin → <em>Dashboard → API-Schlüssel → +</em>.
<strong>ARIAs Brain muss den Server erreichen können</strong> (URL aus Sicht des Servers,
nicht des Browsers).
</div>
<div class="card" style="max-width:780px;">
<div id="jellyfin-list" style="display:flex;flex-direction:column;gap:10px;">
<div style="color:#555570;font-style:italic;">Lade …</div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;">
<button class="btn secondary" onclick="addJellyfinServer()" style="padding:6px 14px;font-size:12px;">+ Server</button>
<button class="btn" onclick="saveJellyfinConfig()" style="padding:6px 14px;font-size:12px;">Speichern</button>
<span id="jellyfin-status" style="color:#8888AA;font-size:11px;"></span>
</div>
<div style="color:#666680;font-size:10px;margin-top:8px;">
API-Keys werden mit Datei-Rechten 0600 gespeichert (/shared/config/jellyfin.json).
</div>
</div>
</div>
<!-- FLUX Bildgenerierung -->
<div class="settings-section">
<h2>FLUX Bildgenerierung</h2>
@@ -4537,6 +4564,7 @@
loadRuntimeConfig();
loadOnboardingQR();
loadOAuthServices();
loadJellyfinConfig();
refreshVoiceIdStatus();
} else if (tab === 'brain') {
loadBrainStatus();
@@ -4743,6 +4771,120 @@
send({ action: 'host_forget', hostId });
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────────
let jellyfinServers = [];
async function loadJellyfinConfig() {
try {
const r = await fetch('/api/jellyfin-config');
const d = await r.json();
jellyfinServers = d.servers || [];
} catch (e) {
jellyfinServers = [];
}
renderJellyfin();
}
function addJellyfinServer() {
jellyfinServers.push({
id: 'jf_' + Date.now().toString(36),
name: '', url: '', apiKey: '',
isDefault: jellyfinServers.length === 0,
});
renderJellyfin();
}
function removeJellyfinServer(i) {
const s = jellyfinServers[i];
if (!confirm('Server "' + (s.name || s.url || 'neu') + '" entfernen?')) return;
jellyfinServers.splice(i, 1);
if (jellyfinServers.length && !jellyfinServers.some(x => x.isDefault)) {
jellyfinServers[0].isDefault = true;
}
renderJellyfin();
}
function setJellyfinDefault(i) {
jellyfinServers.forEach((s, k) => { s.isDefault = (k === i); });
renderJellyfin();
}
function updateJellyfinField(i, field, value) {
if (jellyfinServers[i]) jellyfinServers[i][field] = value;
}
function renderJellyfin() {
const box = document.getElementById('jellyfin-list');
if (!box) return;
if (!jellyfinServers.length) {
box.innerHTML = '<div style="color:#555570;font-style:italic;">' +
'Noch kein Server. Mit „+ Server" einen anlegen.</div>';
return;
}
box.innerHTML = jellyfinServers.map((s, i) =>
'<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;">' +
'<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">' +
'<input type="radio" name="jf-default" ' + (s.isDefault ? 'checked' : '') +
' onchange="setJellyfinDefault(' + i + ')" title="Standard-Server">' +
'<span style="color:#8888AA;font-size:11px;">Standard</span>' +
'<input type="text" value="' + escapeHtml(s.name || '') + '" placeholder="Name (z.B. Zuhause, Kumpel Tim)"' +
' oninput="updateJellyfinField(' + i + ',\'name\',this.value)"' +
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'<button class="btn secondary" onclick="testJellyfinServer(' + i + ')" style="padding:4px 10px;font-size:11px;">Test</button>' +
'<button class="btn secondary" onclick="removeJellyfinServer(' + i + ')" style="padding:4px 9px;font-size:11px;">✕</button>' +
'</div>' +
'<div style="display:flex;gap:8px;">' +
'<input type="text" value="' + escapeHtml(s.url || '') + '" placeholder="http://192.168.0.10:8096"' +
' oninput="updateJellyfinField(' + i + ',\'url\',this.value)"' +
' style="flex:2;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'<input type="password" value="' + escapeHtml(s.apiKey || '') + '" placeholder="API-Key"' +
' oninput="updateJellyfinField(' + i + ',\'apiKey\',this.value)"' +
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'</div>' +
'<div id="jf-test-' + i + '" style="font-size:11px;color:#8888AA;margin-top:6px;"></div>' +
'</div>'
).join('');
}
async function testJellyfinServer(i) {
const s = jellyfinServers[i];
const out = document.getElementById('jf-test-' + i);
if (!s || !out) return;
out.textContent = 'prüfe …';
try {
const r = await fetch('/api/jellyfin-test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: s.url, apiKey: s.apiKey }),
});
const d = await r.json();
out.innerHTML = d.ok
? '<span style="color:#34C759;">✓ erreichbar — ' + escapeHtml(d.name || '') +
' (Version ' + escapeHtml(d.version || '?') + ')</span>'
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(d.error || 'nicht erreichbar') + '</span>';
} catch (e) {
out.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
}
}
async function saveJellyfinConfig() {
const el = document.getElementById('jellyfin-status');
el.textContent = 'speichere …';
try {
const r = await fetch('/api/jellyfin-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers: jellyfinServers }),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
jellyfinServers = d.config.servers || [];
renderJellyfin();
el.innerHTML = '<span style="color:#34C759;">✓ gespeichert (' + jellyfinServers.length + ')</span>';
setTimeout(() => { el.textContent = ''; }, 4000);
} catch (e) {
el.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
}
}
// ── APK-Rollout auf Android/Fire TV (per Satellit + ADB) ─────────
let apkTarget = null; // {satId, ip, name}
let apkRequestId = null;
+114
View File
@@ -328,6 +328,87 @@ function writeLocalLlmConfig(patch) {
return cur;
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────
// /shared/config/jellyfin.json — mehrere Server moeglich (eigener + z.B. vom
// Kumpel). ARIAs Brain liest dieselbe Datei und spricht die HTTP-API direkt an:
// Film suchen -> Sitzung des Abspielgeraets finden -> abspielen. Das ist
// deutlich zuverlaessiger, als sich durch die Fire-TV-Oberflaeche zu klicken.
const JELLYFIN_CONFIG_FILE = "/shared/config/jellyfin.json";
function readJellyfinConfig() {
try {
const p = JSON.parse(fs.readFileSync(JELLYFIN_CONFIG_FILE, "utf-8"));
const servers = Array.isArray(p.servers) ? p.servers : [];
return {
servers: servers.map(s => ({
id: String(s.id || ""),
name: String(s.name || ""),
url: String(s.url || ""),
apiKey: String(s.apiKey || ""),
isDefault: !!s.isDefault,
})).filter(s => s.id && s.url),
};
} catch {
return { servers: [] };
}
}
function writeJellyfinConfig(cfg) {
const servers = (Array.isArray(cfg.servers) ? cfg.servers : [])
.map(s => ({
id: String(s.id || "").trim() || Math.random().toString(36).slice(2, 10),
name: String(s.name || "").trim(),
url: String(s.url || "").trim().replace(/\/+$/, ""), // ohne Slash am Ende
apiKey: String(s.apiKey || "").trim(),
isDefault: !!s.isDefault,
}))
.filter(s => s.url);
// Genau einen Standard-Server markieren (der erste, falls keiner gesetzt ist).
if (servers.length && !servers.some(s => s.isDefault)) servers[0].isDefault = true;
let seen = false;
for (const s of servers) {
if (s.isDefault && seen) s.isDefault = false;
if (s.isDefault) seen = true;
}
fs.mkdirSync("/shared/config", { recursive: true });
const tmp = JELLYFIN_CONFIG_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify({ servers }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, JELLYFIN_CONFIG_FILE);
try { fs.chmodSync(JELLYFIN_CONFIG_FILE, 0o600); } catch (_) {} // API-Keys!
return { servers };
}
/** Erreichbarkeit + Version eines Jellyfin-Servers pruefen. */
function testJellyfin(url, apiKey) {
return new Promise((resolve) => {
let u;
try { u = new URL(url.replace(/\/+$/, "") + "/System/Info"); }
catch (e) { return resolve({ ok: false, error: "Ungültige URL" }); }
const mod = u.protocol === "https:" ? require("https") : require("http");
const req = mod.request(u, {
method: "GET",
timeout: 8000,
headers: { "Authorization": `MediaBrowser Token="${apiKey}"` },
}, (res) => {
let body = "";
res.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
res.on("end", () => {
if (res.statusCode === 401 || res.statusCode === 403) {
return resolve({ ok: false, error: "API-Key abgelehnt (401/403)" });
}
if (res.statusCode !== 200) {
return resolve({ ok: false, error: `HTTP ${res.statusCode}` });
}
try {
const j = JSON.parse(body);
resolve({ ok: true, name: j.ServerName || "", version: j.Version || "" });
} catch (e) { resolve({ ok: false, error: "Antwort nicht lesbar" }); }
});
});
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Zeitüberschreitung" }); });
req.on("error", (e) => resolve({ ok: false, error: e.message }));
req.end();
});
}
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
@@ -2026,6 +2107,39 @@ const server = http.createServer((req, res) => {
}
});
return;
} else if (req.url === "/api/jellyfin-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readJellyfinConfig()));
} else if (req.url === "/api/jellyfin-config" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
req.on("end", () => {
try {
const cfg = writeJellyfinConfig(JSON.parse(body));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, config: cfg }));
log("info", "server", `Jellyfin-Config: ${cfg.servers.length} Server gespeichert`);
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/jellyfin-test" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
req.on("end", async () => {
try {
const { url, apiKey } = JSON.parse(body);
const r = await testJellyfin(String(url || ""), String(apiKey || ""));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(r));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/onboarding") {
// RVS-Credentials fuer QR-Code App-Onboarding
res.writeHead(200, { "Content-Type": "application/json" });