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
+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" });