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