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:
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user