diff --git a/aria-brain/agent.py b/aria-brain/agent.py index b508a9a..130984a 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -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", diff --git a/diagnostic/index.html b/diagnostic/index.html index c63a475..016eadc 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -961,6 +961,33 @@ + +
+

Jellyfin (Filme & Serien)

+
+ 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. +
API-Key: Jellyfin → Dashboard → API-Schlüssel → +. + ARIAs Brain muss den Server erreichen können (URL aus Sicht des Servers, + nicht des Browsers). +
+
+
+
Lade …
+
+
+ + + +
+
+ API-Keys werden mit Datei-Rechten 0600 gespeichert (/shared/config/jellyfin.json). +
+
+
+

FLUX Bildgenerierung

@@ -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 = '
' + + 'Noch kein Server. Mit „+ Server" einen anlegen.
'; + return; + } + box.innerHTML = jellyfinServers.map((s, i) => + '
' + + '
' + + '' + + 'Standard' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + ).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 + ? '✓ erreichbar — ' + escapeHtml(d.name || '') + + ' (Version ' + escapeHtml(d.version || '?') + ')' + : '✗ ' + escapeHtml(d.error || 'nicht erreichbar') + ''; + } catch (e) { + out.innerHTML = '✗ ' + escapeHtml(e.message) + ''; + } + } + + 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 = '✓ gespeichert (' + jellyfinServers.length + ')'; + setTimeout(() => { el.textContent = ''; }, 4000); + } catch (e) { + el.innerHTML = '✗ ' + escapeHtml(e.message) + ''; + } + } + // ── APK-Rollout auf Android/Fire TV (per Satellit + ADB) ───────── let apkTarget = null; // {satId, ip, name} let apkRequestId = null; diff --git a/diagnostic/server.js b/diagnostic/server.js index 3e03e64..a45f9b1 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -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" });