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 @@ + +