diff --git a/compose.yaml b/compose.yaml index 2e224c8..c0a538e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -778,7 +778,8 @@ services: {"url":"http://mike-ai-mcp-github:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[],"function_name_filter_list":"search_repositories,get_file_contents,search_code"},"info":{"id":"github-local","name":"GitHub (offiziell, read-only)","description":"Für Repositorysuche, echte Datei-Inhalte und gezielte Code-Suche. Strikt read-only mit genau drei Werkzeugen; keine rekursiven Komplettbäume, allgemeine Webrecherche oder Änderungen."}}, {"url":"http://mike-ai-mcp-homeassistant:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"homeassistant-local","name":"Home Assistant (lokal)","description":"Für Home-Assistant-Entitäten, Zustände, Historie, Automationen, Dashboards, HA-Diagnose und freigegebene YAML-Dateien. YAML-Lesen ist begrenzt; Änderungen benötigen serverseitige Vorschau, explizite Freigabe, Sicherung und Validierung. Nicht für Unraid, Sonarr/Radarr oder allgemeine Websuche."}}, {"url":"http://mike-ai-mcp-arr:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"arr-local","name":"Sonarr und Radarr (lokal)","description":"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen Vorschau und Freigabe."}}, - {"url":"http://mike-ai-mcp-navidrome:3000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"navidrome-local","name":"Navidrome (Musikbibliothek)","description":"Nur für die persönliche Navidrome-Musikbibliothek: Titel, Alben, Künstler, Playlists, Favoriten und Hörverlauf. Nicht für Sonarr/Radarr, allgemeine Websuche oder Audioausgabe auf dem KI-Host. Wegen des großen Werkzeugkatalogs nur bei Musikaufgaben aktivieren."}} + {"url":"http://mike-ai-mcp-navidrome:3000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"navidrome-local","name":"Navidrome (Musikbibliothek)","description":"Nur für die persönliche Navidrome-Musikbibliothek: Titel, Alben, Künstler, Playlists, Favoriten und Hörverlauf. Nicht für Sonarr/Radarr, allgemeine Websuche oder Audioausgabe auf dem KI-Host. Wegen des großen Werkzeugkatalogs nur bei Musikaufgaben aktivieren."}}, + {"url":"http://mike-ai-mcp-deemix:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"deemix-local","name":"Deemix (bestehende Unraid-Instanz)","description":"Durchsucht Deezer über die vorhandene Deemix-Instanz auf Unraid und verwaltet deren Download-Queue. Keine zweite Deemix-Instanz. Schreibende Queue-Aktionen nur auf ausdrücklichen Benutzerauftrag; Status und Suche sind read-only."}} ] DO_NOT_TRACK: "true" SCARF_NO_ANALYTICS: "true" diff --git a/config/deemix-mcp.env.example b/config/deemix-mcp.env.example new file mode 100644 index 0000000..f800a3b --- /dev/null +++ b/config/deemix-mcp.env.example @@ -0,0 +1,13 @@ +# Root-only auf Athena unter /etc/mike-ai/deemix-mcp.env ablegen (Modus 0600). +# Der Wert gehoert niemals ins Repository. + +# Deemix-Backend auf dem Unraid-HomeServer (ueber WireGuard/Heimnetz). +DEEMIX_URL=http://192.168.1.2:6595 + +# Optionaler Fallback fuer Multi-User-Setups: Deezer-ARL (192-hex-Zeichen). +# Im Single-User-Modus liefert Deemix den gespeicherten ARL selbst ueber +# GET /connect (singleUser.arl) und dieser Wert bleibt leer. +DEEMIX_ARL= + +# Optional: HTTP-Timeout in Sekunden (Standard 30). +#DEEMIX_TIMEOUT=30 diff --git a/dev/test_deemix_mcp.py b/dev/test_deemix_mcp.py new file mode 100644 index 0000000..1b163b5 --- /dev/null +++ b/dev/test_deemix_mcp.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +"""Reproducible test suite for the Deemix MCP server. + +Run: python test_deemix_mcp.py (from the package directory) + +The suite spins up a local HTTP server that emulates the Deemix webui API +(connect / search / getQueue / addToQueue / loginArl / removeFromQueue / +retryDownload / removeFinishedDownloads / cancelAllDownloads) and points the +MCP at it. No real Deezer access, no real downloads, no queue residue: + + * read + auth flow (fresh first call, one-shot re-login on NotLoggedIn, + 401, 403, re-login failure abort) + * bitrate validation (valid TrackFormats ids, invalid rejected, not kbit/s) + * url/term/result limits + * invalid url/id handling + * compact queue output + * secret redaction (ARL, cookie headers, long hex) + +Write-path tools that would enqueue real downloads are verified here ONLY +against the emulated backend; against production Deemix they are considered +verified against the source code, not practically executed. +""" + +from __future__ import annotations + +import json +import os +import sys +import threading +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Optional + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +# -------------------------------------------------------------------------- +# Emulated Deemix backend +# -------------------------------------------------------------------------- +FAKE_ARL = "a1" * 96 # 192 hex chars, like a real Deezer ARL + + +class DeemixState: + def __init__(self) -> None: + self.logged_in = False + self.connect_calls = 0 + self.login_calls = 0 + self.requests: list[str] = [] + self.fail_next_relogin = False + self.force_401_once = False + self.force_403_once = False + self.queue = { + "album_1_1": { + "uuid": "album_1_1", "type": "album", "id": "1", + "title": "Folge 240", "artist": "Die drei ???", + "progress": 37, "downloaded": 52428800, "size": 80530636, + "failed": False, "errors": [], + }, + "album_2_3": { + "uuid": "album_2_3", "type": "album", "id": "2", + "title": "Folge 239", "artist": "Die drei ???", + "progress": 100, "downloaded": 76000000, "size": 76000000, + "failed": True, + "errors": ["Tagging failed: unknown picture MIME", + "second error", "third error", "fourth error"], + }, + } + self.added: list[dict] = [] + self.cancelled_all = False + + +STATE = DeemixState() + + +class EmulatedDeemix(BaseHTTPRequestHandler): + def log_message(self, *a): # quiet + pass + + def _send(self, obj: dict, status: int = 200) -> None: + body = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _auth_required(self) -> bool: + """True when the session cookie is missing (not logged in).""" + cookie = self.headers.get("Cookie", "") + return "deemix_session" not in cookie + + def do_GET(self): + STATE.requests.append("GET " + self.path) + if self.path.startswith("/deemix/api/connect"): + STATE.connect_calls += 1 + # In real Deemix the session IS the cookie: without it the + # server reports singleUser + the ARL again, so a client can + # re-login after any session loss. + authed = not self._auth_required() + obj: dict = { + "update": {"deemixVersion": "3.13.7", "webuiVersion": "4.6.0"}, + "autologin": not authed, + "currentUser": None, + "deezerAvailable": "yes", + "spotifyEnabled": True, + "settingsData": {"settings": { + "downloadLocation": "/downloads", "maxBitrate": 9, + }}, + } + if not authed: + obj["singleUser"] = {"arl": FAKE_ARL} + else: + obj["currentUser"] = {"id": 12345, "name": "Test", + "lastName": "User", "country": "DE", + "subscription": "premium"} + self._send(obj) + return + if self._force_auth_error(): + return + if self._auth_required(): + self._send({"result": False, "errid": "NotLoggedIn"}) + return + if self.path.startswith("/deemix/api/search"): + q = urllib.parse.parse_qs(self.path.split("?", 1)[1]) if "?" in self.path else {} + nb = int(q.get("nb", ["10"])[0]) + total = 31 + data = [{"id": 985501051, "title": "Die drei ??? Folge 240", + "release_date": "2026-08-18", "nb_tracks": 48, + "related": {"artists": [{"name": "Die drei ???"}, + {"name": "Mystery-Club"}]}}] + data += [{"id": i, "title": f"Treffer {i}", + "release_date": "2020-01-01", "nb_tracks": 10, + "related": {"artists": [{"name": "K"}]}} for i in range(1, total)] + self._send({"data": data[:nb], "total": total, "type": "album", + "error": ""}) + return + if self.path.startswith("/deemix/api/getQueue"): + self._send({"queue": dict(STATE.queue), + "queueOrder": list(STATE.queue.keys())}) + return + self._send({"result": False, "errid": "NotFound"}, 404) + + def _force_auth_error(self) -> bool: + """Emulate a session that the server rejects with HTTP 401/403.""" + if STATE.force_401_once: + STATE.force_401_once = False + self._send({"result": False, "errid": "http401"}, 401) + return True + if STATE.force_403_once: + STATE.force_403_once = False + self._send({"result": False, "errid": "http403"}, 403) + return True + return False + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length).decode() if length else "" + try: + body = json.loads(raw) if raw else {} + except json.JSONDecodeError: + body = {} + STATE.requests.append("POST " + self.path) + if self.path.startswith("/deemix/api/loginArl"): + STATE.login_calls += 1 + if STATE.fail_next_relogin: + STATE.fail_next_relogin = False + self._send({"status": 0, "error": "invalidArl"}) + return + if body.get("arl") != FAKE_ARL: + self._send({"status": 0, "error": "invalidArl"}) + return + STATE.logged_in = True + # Emulate setting the session cookie (real Deemix sets a cookie). + self.send_response(200) + self.send_header("Set-Cookie", "deemix_session=abc123; Path=/") + self.send_header("Content-Type", "application/json") + payload = json.dumps({"status": 1, "user": {"id": 12345, + "name": "Test", + "lastName": "User", + "country": "DE"}}) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload.encode()) + return + if self._auth_required(): + self._send({"result": False, "errid": "NotLoggedIn"}) + return + if self._force_auth_error(): + return + if self.path.startswith("/deemix/api/addToQueue"): + url = body.get("url", "") + bitrate = body.get("bitrate") + # Mirror the real route: empty/"null" falls back to settings. + if bitrate == "null" or not bitrate: + bitrate = 9 + bitrate = int(bitrate) + if "badid" in url or "9999999999" in url: + # Real Deemix behaviour: an unknown ID is accepted (result + # true) but resolves to zero items, so nothing is queued. + self._send({"result": True, "data": { + "url": [u for u in url.split()], "bitrate": bitrate, + "obj": []}}) + return + STATE.added.append({"url": url, "bitrate": bitrate}) + ids = [u.rsplit("/", 1)[-1] for u in url.split()] + self._send({"result": True, "data": { + "url": ids, "bitrate": bitrate, + "obj": [{"id": i, "type": "album"} for i in ids]}}) + return + if self.path.startswith("/deemix/api/removeFromQueue"): + uuid = urllib.parse.parse_qs(self.path.split("?", 1)[1]).get("uuid", [""])[0] + if uuid not in STATE.queue: + self._send({"result": False, "errid": "UnknownUuid"}) + return + del STATE.queue[uuid] + self._send({"result": True}) + return + if self.path.startswith("/deemix/api/retryDownload"): + uuid = body.get("uuid", "") + if uuid not in STATE.queue: + self._send({"result": False, "errid": "UnknownUuid"}) + return + STATE.queue[uuid]["failed"] = False + self._send({"result": True}) + return + if self.path.startswith("/deemix/api/removeFinishedDownloads"): + for k in list(STATE.queue): + if not STATE.queue[k]["failed"]: + del STATE.queue[k] + self._send({"result": True}) + return + if self.path.startswith("/deemix/api/cancelAllDownloads"): + STATE.cancelled_all = True + self._send({"result": True}) + return + self._send({"result": False, "errid": "NotFound"}, 404) + + +# -------------------------------------------------------------------------- +# Tests +# -------------------------------------------------------------------------- +import urllib.parse # noqa: E402 (used by the emulated handler above) + +PASS: list[str] = [] +FAIL: list[str] = [] + + +def check(name: str, cond: bool, detail: str = "") -> None: + (PASS if cond else FAIL).append(name) + print(("PASS " if cond else "FAIL ") + name + (f" [{detail}]" if detail and not cond else "")) + + +def main() -> int: + server = ThreadingHTTPServer(("127.0.0.1", 0), EmulatedDeemix) + port = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + + os.environ["DEEMIX_URL"] = f"http://127.0.0.1:{port}" + os.environ["DEEMIX_ARL"] = "" # force single-user path + # fresh import so module-level env reads pick up the emulated URL + if "deemix_mcp" in sys.modules: + del sys.modules["deemix_mcp"] + import importlib + import deemix_mcp + importlib.reload(deemix_mcp) + from deemix_mcp import (Session, DeemixError, redact, _clean_error, + deemix_status, deemix_search, deemix_add_to_queue, + deemix_queue, deemix_retry_download, + deemix_remove_from_queue, TRACKFORMATS, + MAX_URLS_PER_CALL, MAX_SEARCH_RESULTS, + MAX_SEARCH_TERM) + + S = deemix_mcp.SESSION + S._logged_in = False + S.jar.clear() + + # 1) fresh first call right after "container start" -------------------- + out = json.loads(deemix_status()) + check("erstaufruf: status nach frischem Start", + out["logged_in_user"].get("name") == "Test", str(out.get("logged_in_user"))) + check("erstaufruf: ARL nicht in Status-Output", + FAKE_ARL not in json.dumps(out)) + check("erstaufruf: genau ein Login-Versuch", + STATE.login_calls == 1, f"login_calls={STATE.login_calls}") + + # 2) direct first call of search (session already warm) ----------------- + out = json.loads(deemix_search("Die drei ??? Folge 240")) + check("erstaufruf: search funktioniert", + out["total"] == 31 and len(out["results"]) == 10) + check("search: Deezer-Link praeSENT", + out["results"][0]["link"] == "https://www.deezer.com/album/985501051") + + out = json.loads(deemix_queue()) + check("erstaufruf: queue funktioniert", out["total"] == 2) + check("queue: kompakt (keine groben Rohfelder)", + all("downloaded_mb" in r for r in out["rows"]) and + all("size" not in r for r in out["rows"])) + check("queue: errors auf 3 begrenzt", + [r for r in out["rows"] if r["uuid"] == "album_2_3"][0]["errors"] and + len([r for r in out["rows"] if r["uuid"] == "album_2_3"][0]["errors"]) == 3) + + # 3) automatic ONE re-login on NotExpired session ------------------------ + S._logged_in = True + S.jar.clear() # server now sees no session cookie -> NotLoggedIn + out = json.loads(deemix_queue()) + check("re-login: NotLoggedIn wird automatisch einxmal behoben", + out["total"] == 2) + check("re-login: genau EIN zusaeztlicher Login", + STATE.login_calls == 2, f"login_calls={STATE.login_calls}") + + # 4) HTTP 401 triggers the same bounded re-login ------------------------- + STATE.force_401_once = True + S._logged_in = True + out = json.loads(deemix_queue()) + check("re-login: HTTP 401 wird automatisch einxmal behoben", + out["total"] == 2 and STATE.login_calls == 3) + + # 5) HTTP 403 likewise ---------------------------------------------------- + STATE.force_403_once = True + out = json.loads(deemix_queue()) + check("re-login: HTTP 403 wird automatisch einxmal behoben", + out["total"] == 2 and STATE.login_calls == 4) + + # 6) failed re-login aborts (no loop) ------------------------------------ + S._logged_in = False + S.jar.clear() + STATE.fail_next_relogin = True + try: + deemix_queue() + check("re-login: fehlgeschlagener Re-Login bricht ab", False, "kein Fehler geworfen") + except DeemixError as e: + ok = "re-login" in str(e).lower() or "session" in str(e).lower() + check("re-login: fehlgeschlagener Re-Login bricht ab", ok, str(e)) + check("re-login: kein Endlosschleifen-Verhalten (max. 1 Versuch)", + STATE.login_calls == 5, f"login_calls={STATE.login_calls}") + + # restore a working session for the remaining tests + S._logged_in = False + S.jar.clear() + deemix_status() + + # 7) bitrate validation ---------------------------------------------------- + out = json.loads(deemix_add_to_queue( + "https://www.deezer.com/album/985501051", bitrate=3)) + check("bitrate: 3 (MP3 320) akzeptiert und uebergeben", + out["queued"] and out["bitrate_used"] == 3) + out = json.loads(deemix_add_to_queue( + "https://www.deezer.com/album/985501051", bitrate=9)) + check("bitrate: 9 (FLAC) akzeptiert", out["queued"] and out["bitrate_used"] == 9) + out = json.loads(deemix_add_to_queue( + "https://www.deezer.com/album/985501051", bitrate=1)) + check("bitrate: 1 (MP3 128) akzeptiert", out["queued"] and out["bitrate_used"] == 1) + out = json.loads(deemix_add_to_queue("https://www.deezer.com/album/985501051")) + check("bitrate: weggelassen -> Deemix-Setting (9) greift", + out["bitrate_requested"] is None and out["bitrate_used"] == 9) + try: + deemix_add_to_queue("https://www.deezer.com/album/985501051", bitrate=320) + check("bitrate: 320 (kbit/s-Irrtum) wird abgelehnt", False, "kein Fehler") + except DeemixError as e: + check("bitrate: 320 (kbit/s-Irrtum) wird abgelehnt", + "320" in str(e) and "not a valid" in str(e), str(e)) + try: + deemix_add_to_queue("https://www.deezer.com/album/985501051", bitrate=0) + check("bitrate: 0 (LOCAL) wird abgelehnt", False, "kein Fehler") + except DeemixError: + check("bitrate: 0 (LOCAL) wird abgelehnt", True) + check("bitrate: nur die WebUI-Werte 1/3/9 sind gueltig", + set(TRACKFORMATS) == {1, 3, 9}, str(TRACKFORMATS)) + + # 8) url limits ----------------------------------------------------------- + many = " ".join(f"https://www.deezer.com/album/{i}" for i in range(MAX_URLS_PER_CALL + 1)) + try: + deemix_add_to_queue(many) + check("limit: >10 URLs abgelehnt", False, "kein Fehler") + except DeemixError as e: + check("limit: >10 URLs abgelehnt", "11" in str(e), str(e)) + ok10 = " ".join(f"https://www.deezer.com/album/{i}" for i in range(10)) + out = json.loads(deemix_add_to_queue(ok10)) + check("limit: genau 10 URLs akzeptiert", out["queued"]) + + # 9) search limits ---------------------------------------------------------- + out = json.loads(deemix_search("x" * (MAX_SEARCH_TERM + 50), nb=999)) + check("limit: Suchtext wird auf 100 Zeichen gekuerzt", + out["total"] == 31) # emulated server answers regardless; no crash + out = json.loads(deemix_search("test", nb=999)) + check("limit: nb wird auf 20 begrenzt", + len(out["results"]) <= MAX_SEARCH_RESULTS) + + # 10) invalid url / id -------------------------------------------------------- + # Real Deemix behaviour: unknown IDs are accepted but resolve to zero + # items -> the tool reports queued=false with a warning and nothing is + # queued (verified against production Deemix 3.13.7 / webui 4.6.0). + out = json.loads(deemix_add_to_queue("https://www.deeeeezer.com/badid/9999999999")) + check("ungueltige URL/ID: wird ehrlich gemeldet (queued=false + Warnung)", + out["queued"] is False and "NO items" in (out.get("warning") or ""), + str(out)) + check("ungueltige URL/ID: nichts in der Queue (kein Download)", + STATE.added == [] or all("9999999999" not in a["url"] for a in STATE.added), + str(STATE.added)) + try: + deemix_add_to_queue("") + check("leere urls abgelehnt", False, "kein Fehler") + except DeemixError: + check("leere urls abgelehnt", True) + try: + deemix_retry_download("album_1_1") + check("retry: vorhandene UUID ok", True) + except DeemixError as e: + check("retry: vorhandene UUID ok", False, str(e)) + try: + deemix_retry_download("album_kein_kein") + check("retry: unbekannte UUID sauberer Fehler", False, "kein Fehler") + except DeemixError as e: + check("retry: unbekannte UUID sauberer Fehler", "UnknownUuid" in str(e), str(e)) + + # 11) remove / finished (against emulated backend only) ---------------------- + out = json.loads(deemix_remove_from_queue("album_2_3")) + check("remove_from_queue: UUID entfernt", out["result"] and + "album_2_3" not in STATE.queue) + out = json.loads(deemix_queue()) + check("queue: nach Entfernen nur 1 Eintrag", out["total"] == 1) + try: + deemix_remove_from_queue("album_unbekannt") + check("remove_from_queue: unbekannte UUID sauberer Fehler", False, "kein Fehler") + except DeemixError: + check("remove_from_queue: unbekannte UUID sauberer Fehler", True) + + # 12) secret redaction --------------------------------------------------------- + blob = { + "arl": FAKE_ARL, + "cookie": "deemix_session=abc123", + "note": f"token {FAKE_ARL[:100]} embedded", + "nested": {"set-cookie": "a=1", "ok": "bleibt"}, + "list": [FAKE_ARL], + } + red = redact(blob) + s = json.dumps(red) + check("redaction: ARL verschwaengt", FAKE_ARL not in s) + check("redaction: Cookie-Feld verschwaengt", + red["cookie"] == "[REDACTED]" and red["nested"]["set-cookie"] == "[REDACTED]") + check("redaction: unfaellige Felder bleiben", + red["nested"]["ok"] == "bleibt") + check("redaction: lange Hexfolgen werden maskiert", + "[REDACTED]" in s) + msg = _clean_error(f"boom arl={FAKE_ARL}") + check("redaction: Fehlermeldungen werden bereinigt", + FAKE_ARL not in msg and "[REDACTED]" in msg) + + # 13) compact queue output ------------------------------------------------------ + big = [{"uuid": f"u{i}", "type": "album", "id": i, "title": "t", "artist": "a", + "progress": i, "downloaded": 1048576 * i, "size": 2097152 * i, + "failed": i % 7 == 0} for i in range(60)] + q = json.loads(deemix_queue()) + check("queue: Ausgabe kompakt und begrenzt", + q["total"] <= 50 or q.get("truncated")) + + server.shutdown() + + print(f"\n{len(PASS)} bestanden, {len(FAIL)} fehlgeschlagen") + if FAIL: + print("Fehlschlaege:", *FAIL, sep="\n - ") + return 1 + print("ALLE TESTS BESTANDEN (emuliertes Deemix-Backend, keine echten Downloads)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/CURRENT_REFERENCE.md b/docs/CURRENT_REFERENCE.md index 3d0399f..2faa5a8 100644 --- a/docs/CURRENT_REFERENCE.md +++ b/docs/CURRENT_REFERENCE.md @@ -386,3 +386,11 @@ Zusätzliche bekannte Sicherheitsabweichung: llama.cpp auf Port 8080 und XTTS auf Port 8085 sind im alten Zustand breiter gebunden als im Zielsystem. Beim Neuaufbau werden beide auf localhost begrenzt; Clients verwenden ausschließlich den Router auf Port 8081. + +### Deemix MCP + +- Backend bleibt die bestehende Unraid-Instanz `192.168.1.2:6595`. +- Athena betreibt nur den API-Client `mike-ai-mcp-deemix`; kein zweites Deemix. +- Aktivierung über `/etc/mike-ai/deemix-mcp.env` (root-only, `0600`). +- Hermes und OpenWebUI nutzen das private Docker-Werkzeugnetz; kein zusätzlicher + VPN-Port und keine WireGuard-Änderung sind erforderlich. diff --git a/platform/hermes/config.yaml b/platform/hermes/config.yaml index 64b09d7..f14b96a 100644 --- a/platform/hermes/config.yaml +++ b/platform/hermes/config.yaml @@ -156,6 +156,11 @@ mcp_servers: timeout: 300 connect_timeout: 30 supports_parallel_tool_calls: false + deemix: + url: "http://mcp-deemix:8000/mcp" + timeout: 300 + connect_timeout: 30 + supports_parallel_tool_calls: false unraid: url: "${MUA_MCP_URL}" headers: diff --git a/platform/mcp/Dockerfile.deemix b/platform/mcp/Dockerfile.deemix new file mode 100644 index 0000000..a8145fb --- /dev/null +++ b/platform/mcp/Dockerfile.deemix @@ -0,0 +1,13 @@ +FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a + +ARG MCP_VERSION=1.29.0 +RUN pip install --no-cache-dir "mcp==${MCP_VERSION}" \ + && groupadd --system --gid 10005 deemixmcp \ + && useradd --system --uid 10005 --gid 10005 --no-create-home deemixmcp +COPY deemix_mcp.py /app/deemix_mcp.py +RUN chown -R 10005:10005 /app +USER 10005:10005 +WORKDIR /app +ENV PYTHONUNBUFFERED=1 +EXPOSE 8000 +ENTRYPOINT ["python", "/app/deemix_mcp.py"] diff --git a/platform/mcp/README.md b/platform/mcp/README.md index 8fbac0d..643bb4b 100644 --- a/platform/mcp/README.md +++ b/platform/mcp/README.md @@ -327,3 +327,16 @@ bereits die Vorschau nach gesonderter Zustimmung `force=true` enthalten. Das Ticket ist auch daran gebunden. Der MCP löscht keine vorhandenen Dateien und verspricht keine Überschreibung: Ob eine vorhandene Episode nach dem Download ersetzt wird, entscheiden Sonarrs Qualitätsprofil-, Upgrade- und Importregeln. + +## Deemix MCP + +`mcp-deemix` steuert ausschließlich die bereits vorhandene Deemix-Instanz auf +Unraid unter `192.168.1.2:6595`. Es installiert kein zweites Deemix. Die +root-only Datei `/etc/mike-ai/deemix-mcp.env` aktiviert das Compose-Profil. +Hermes erreicht den Dienst intern als `http://mcp-deemix:8000/mcp`, OpenWebUI +als `http://mike-ai-mcp-deemix:8000/mcp`. Im Single-User-Modus übernimmt der +MCP die in Deemix gespeicherte Anmeldung nur kurzzeitig im Arbeitsspeicher; +Secrets werden nicht in Tool-Ausgaben zurückgegeben. Nach Änderungen immer +Handshake, `deemix_status`, eine begrenzte Suche und eine unveränderte Queue +prüfen. Reinstall: Env-Beispiel nach `/etc/mike-ai/deemix-mcp.env` kopieren, +Modus `0600` setzen und `platform/mcp/install-tools.sh` ausführen. diff --git a/platform/mcp/compose.yaml b/platform/mcp/compose.yaml index 8743fa6..f321ad7 100644 --- a/platform/mcp/compose.yaml +++ b/platform/mcp/compose.yaml @@ -155,6 +155,26 @@ services: - /config:rw,noexec,nosuid,nodev,size=4m,mode=0700 networks: [tools, egress] + mcp-deemix: + <<: *tool-common + build: + context: . + dockerfile: Dockerfile.deemix + image: mike-ai/mcp-deemix:1.0.0 + container_name: mike-ai-mcp-deemix + profiles: [deemix] + env_file: + - ${DEEMIX_MCP_ENV_FILE:-/etc/mike-ai/deemix-mcp.env} + environment: + PORT: "8000" + networks: [tools, egress] + healthcheck: + test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 10s + mcp-platform-context: <<: *tool-common build: diff --git a/platform/mcp/deemix_mcp.py b/platform/mcp/deemix_mcp.py new file mode 100644 index 0000000..efa8c31 --- /dev/null +++ b/platform/mcp/deemix_mcp.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +"""mike-ai MCP: Deemix. + +Drives the existing Deemix instance on the Unraid home server through its +Web-UI HTTP API (http://:6595/deemix/api/*). Deemix ships no CLI and +no documented public REST API; the endpoints used here were verified against +the route files of webui 4.6.0 / deemix 3.13.7 (dist/routes/api/{get,post}). + +Credential handling (honest documentation): + * No credential is stored on Athena or in this container on disk. + * In single-user mode the Deemix app itself serves the saved Deezer ARL from + GET /connect (singleUser.arl). The MCP reads it into process memory ONLY + for the duration of a (re-)login and never writes it to disk, logs, tool + output or error messages. DEEMIX_ARL is only a fallback for multi-user + setups and is read from the environment. + * The ARL is therefore transiently present in the MCP's working memory + between a /connect read and the corresponding /loginArl call, and in the + cookie jar afterwards. It is redacted from anything that leaves the + process. + +Session handling: + * Every tool funnels through Session.call(), which guarantees a login before + the first request (so a direct first call right after a fresh container + start works) and, if Deemix reports an expired/absent session + (errid NotLoggedIn) or answers HTTP 401/403, performs AT MOST ONE + automatic re-login and retries the request once. There is no retry loop. +""" + +from __future__ import annotations + +import http.cookiejar +import json +import os +import re +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Optional + +from mcp.server.fastmcp import FastMCP + +DEEMIX_URL = os.environ.get("DEEMIX_URL", "http://192.168.1.2:6595").rstrip("/") +API = DEEMIX_URL + "/deemix/api" +FALLBACK_ARL = os.environ.get("DEEMIX_ARL", "").strip() +TIMEOUT = float(os.environ.get("DEEMIX_TIMEOUT", "30")) + +# --- Limits (kept small and bounded so tool output stays compact) --------- +MAX_QUEUE_ROWS = 50 # max queue entries returned per call +MAX_SEARCH_RESULTS = 20 # hard cap on search result rows +MAX_SEARCH_TERM = 100 # max characters in a search term +MAX_URLS_PER_CALL = 10 # max Deezer URLs accepted by deemix_add_to_queue +MAX_ERRORS_PER_ENTRY = 3 + +# --- Bitrate: verified against the deployed webui/deezer-sdk code --------- +# `addToQueue.js` does `bitrate = Number(bitrate)` and falls back to the +# Deemix `maxBitrate` setting; it is NOT a kbit/s figure. The values are +# deezer-sdk `TrackFormats` enum ids. The Deemix settings UI offers exactly: +TRACKFORMATS: dict[int, str] = { + 9: "FLAC (lossless, ~1411 kbps)", + 3: "MP3 320 kbps", + 1: "MP3 128 kbps", +} +VALID_BITRATES = tuple(sorted(TRACKFORMATS)) # (1, 3, 9) + +# --- Secret redaction ------------------------------------------------------ +# Long hex runs (Deezer ARL is a long lowercase-hex cookie) are masked, as is +# any value that matches a secret we actually hold, plus cookie headers. +_HEX_RUN = re.compile(r"[0-9a-fA-F]{32,}") +_COOKIE_LINE = re.compile(r"(?im)^\s*(set-?cookie|cookie)\s*:\s*.*$") +_KNOWN_SECRETS: list[str] = [s for s in {FALLBACK_ARL, os.environ.get("DEEMIX_ARL", "").strip()} if s] + + +def redact(value: Any) -> Any: + """Recursively strip anything that could carry a credential.""" + if isinstance(value, dict): + out: dict = {} + for k, v in value.items(): + if isinstance(k, str) and k.lower() in ("arl", "cookie", "cookies", "set-cookie", "token", "authorization"): + out[k] = "[REDACTED]" + else: + out[k] = redact(v) + return out + if isinstance(value, (list, tuple)): + return [redact(v) for v in value] + if isinstance(value, str): + s = value + for sec in _KNOWN_SECRETS: + s = s.replace(sec, "[REDACTED]") + s = _HEX_RUN.sub("[REDACTED]", s) + s = _COOKIE_LINE.sub("cookie: [REDACTED]", s) + return s + return value + + +def _clean_error(message: str) -> str: + """Trim + redact an error so no credential/cookie/raw body escapes.""" + s = str(message).replace("\n", " ") + for sec in _KNOWN_SECRETS: + s = s.replace(sec, "[REDACTED]") + s = _HEX_RUN.sub("[REDACTED]", s) + return s[:300] + + +mcp = FastMCP( + "deemix", + instructions=( + "Control the Deemix download server on the Unraid home server: " + "search Deezer, queue albums/tracks, monitor download progress, " + "retry failures. Files land on Unraid at the configured download " + "location (see deemix_status). No credentials are stored here." + ), + host="0.0.0.0", + port=int(os.environ.get("PORT", "8000")), + stateless_http=True, +) + + +class DeemixError(RuntimeError): + """User-facing error with a short, actionable, credential-free message.""" + + +class AuthError(DeemixError): + """The Deemix session is missing or expired; a re-login may help.""" + + +class Session: + """One HTTP session against Deemix with bounded automatic (re-)login.""" + + def __init__(self) -> None: + self.jar = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.jar) + ) + self.user: dict = {} + self._logged_in = False + + # -- low level --------------------------------------------------------- + def _headers(self) -> dict: + return { + "Accept": "application/json", + "User-Agent": "mike-ai-mcp-deemix/1.1", + } + + def _raw(self, method: str, path: str, body: Optional[dict] = None): + """Return (http_status, parsed). Network failures raise DeemixError. + + HTTP 401/403 are returned (not raised) so the caller can decide on a + re-login. Any non-JSON body is turned into a clean, redacted error. + """ + url = API + path + data = json.dumps(body).encode() if body is not None else None + headers = self._headers() + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + resp = self.opener.open(req, timeout=TIMEOUT) + status = resp.getcode() + raw = resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + if e.code in (401, 403): + return e.code, None + raise DeemixError( + _clean_error(f"Deemix {path} -> HTTP {e.code}") + ) from None + except (urllib.error.URLError, TimeoutError, OSError) as e: + raise DeemixError( + _clean_error(f"Deemix unreachable at {API}: {getattr(e, 'reason', e)}") + ) from None + if not raw.strip(): + return status, {} + try: + return status, json.loads(raw) + except json.JSONDecodeError: + raise DeemixError( + _clean_error(f"Deemix {path} returned non-JSON ({raw[:80]!r})") + ) from None + + # -- login ------------------------------------------------------------- + def _jar_has_session(self) -> bool: + return any(c.name == "deemix_session" for c in self.jar) + + def login(self) -> None: + """Establish a session. Raises DeemixError on failure (no retry).""" + status, info = self._raw("GET", "/connect") + if status in (401, 403): + # Not authenticated yet; /connect should still answer, but if it + # refuses we treat it as "needs login" and fall through. + info = info or {} + if not isinstance(info, dict): + info = {} + if str(info.get("deezerAvailable")) != "yes": + raise DeemixError( + "Deemix reports Deezer unavailable " + f"(deezerAvailable={info.get('deezerAvailable')!r}) - " + "check the Unraid VPN tunnel" + ) + if not info.get("autologin", True) and self._jar_has_session(): + # autologin=false is only trusted while a live session cookie is + # actually held; after a cookie loss a re-login is required even + # though the server still reports autologin=false. + self._logged_in = True + self.user = info.get("currentUser") or {} + return + arl = ((info.get("singleUser") or {}).get("arl") or "") or FALLBACK_ARL + if not arl: + raise DeemixError( + "No ARL available: Deemix is not in single-user mode and " + "DEEMIX_ARL is not set in the MCP environment" + ) + status, res = self._raw("POST", "/loginArl", {"arl": arl}) + if status in (401, 403) or not isinstance(res, dict) or res.get("status") not in (1, 2, 3): + raise DeemixError( + f"Deezer ARL login failed (status=" + f"{(res or {}).get('status') if isinstance(res, dict) else status}, " + f"error={(res or {}).get('error') if isinstance(res, dict) else 'http'})" + ) + self._logged_in = True + self.user = res.get("user") or {} + + # -- central call with at most ONE re-login ---------------------------- + def call(self, method: str, path: str, body: Optional[dict] = None) -> Any: + for _attempt in (1, 2): + if not self._logged_in: + try: + self.login() + except DeemixError as e: + # The login (or the automatic re-login) failed. Abort + # cleanly and boundedly: we never loop, we raise after + # this single failed attempt. + raise DeemixError( + "Deemix session could not be established: " + f"login/re-login attempt failed - {_clean_error(e)}" + ) from None + status, res = self._raw(method, path, body) + if status in (401, 403): + # Expiring session: reset and allow exactly one re-login. + self._logged_in = False + self.jar.clear() + continue + if ( + isinstance(res, dict) + and res.get("result") is False + and res.get("errid") == "NotLoggedIn" + ): + self._logged_in = False + self.jar.clear() + continue + return res + raise DeemixError( + "Deemix session could not be established: re-login was attempted " + "once and the request still reports an expired/absent session." + ) + + def request(self, method: str, path: str, body: Optional[dict] = None) -> Any: + """Back-compat alias used by tools that only need a read.""" + return self.call(method, path, body) + + +SESSION = Session() + + +def call(method: str, path: str, body: Optional[dict] = None) -> Any: + return SESSION.call(method, path, body) + + +def _out(payload: Any) -> str: + return json.dumps(redact(payload), ensure_ascii=False, indent=2) + + +def _compact_entry(e: dict) -> dict: + size = e.get("size") or 0 + row = { + "uuid": e.get("uuid"), + "type": e.get("type"), + "id": e.get("id"), + "title": e.get("title"), + "artist": e.get("artist"), + "progress": e.get("progress"), + "downloaded_mb": round((e.get("downloaded") or 0) / 1048576, 1), + "total_mb": round(size / 1048576, 1) if size else None, + "failed": bool(e.get("failed")), + } + errs = e.get("errors") + if errs: + row["errors"] = redact(errs)[:MAX_ERRORS_PER_ENTRY] + return row + + +def _compact_search_row(item: dict, kind: str) -> dict: + if kind == "album": + artists = ", ".join( + a.get("name", "") for a in (item.get("related") or {}).get("artists", [])[:2] + ) + return { + "id": item.get("id"), + "title": item.get("title"), + "artist": artists, + "year": ((item.get("release_date") or "")[:4] or None), + "tracks": item.get("nb_tracks"), + "link": f"https://www.deezer.com/album/{item.get('id')}", + } + if kind == "track": + return { + "id": item.get("id"), + "title": item.get("title"), + "artist": (item.get("artist") or {}).get("name"), + "album": (item.get("album") or {}).get("title"), + "duration": item.get("duration"), + "link": f"https://www.deezer.com/track/{item.get('id')}", + } + if kind == "artist": + return { + "id": item.get("id"), + "name": item.get("name"), + "link": f"https://www.deezer.com/artist/{item.get('id')}", + } + if kind == "playlist": + return { + "id": item.get("id"), + "title": item.get("name") or item.get("title"), + "nb_tracks": item.get("nb_tracks"), + "link": f"https://www.deezer.com/playlist/{item.get('id')}", + } + return { + k: item.get(k) + for k in ("id", "name", "title", "link", "url") + if item.get(k) is not None + } + + +def _valid_bitrate(bitrate: Optional[int]) -> Optional[int]: + if bitrate is None: + return None + if bitrate not in TRACKFORMATS: + raise DeemixError( + f"bitrate {bitrate} is not a valid TrackFormats value. " + f"Use one of: {', '.join(f'{b}={TRACKFORMATS[b]}' for b in VALID_BITRATES)}. " + "These are deezer-sdk TrackFormats enum ids, not kbit/s; " + "omit bitrate to use Deemix's own maxBitrate setting." + ) + return bitrate + + +@mcp.tool() +def deemix_status() -> str: + """Status of the Deemix server: versions, Deezer login, download location and a compact queue summary.""" + info = call("GET", "/connect") # guarantees a session (also first call after fresh start) + queue = call("GET", "/getQueue") + entries = list((queue.get("queue") or {}).values()) + order = queue.get("queueOrder") or [] + by_id = {e.get("uuid"): e for e in entries} + ordered = [by_id[u] for u in order if u in by_id] + for e in entries: + if e.get("uuid") not in order: + ordered.append(e) + rows = [_compact_entry(e) for e in ordered[:MAX_QUEUE_ROWS]] + u = SESSION.user or {} + settings = (info.get("settingsData") or {}).get("settings") or {} + user_view = { + k: u.get(k) + for k in ("id", "name", "lastName", "country", "subscription") + if u.get(k) not in (None, "") + } + return _out( + { + "deemix_version": (info.get("update") or {}).get("deemixVersion"), + "webui_version": (info.get("update") or {}).get("webuiVersion"), + "deezer_available": info.get("deezerAvailable"), + "logged_in_user": user_view or "not logged in", + "spotify_enabled": info.get("spotifyEnabled"), + "download_location": settings.get("downloadLocation"), + "max_bitrate_setting": settings.get("maxBitrate"), + "queue": { + "total": len(entries), + "failed": sum(1 for e in entries if e.get("failed")), + "rows": rows, + "truncated": len(entries) > MAX_QUEUE_ROWS, + }, + } + ) + + +@mcp.tool() +def deemix_search(term: str, type: str = "album", start: int = 0, nb: int = 10) -> str: + """Search Deezer via Deemix. type: album, track, artist, playlist or radio. + Returns compact rows with Deezer links usable for deemix_add_to_queue. + term is limited to 100 chars and nb is capped at 20 results.""" + term = (term or "").strip() + if not term: + raise DeemixError("term is empty") + if len(term) > MAX_SEARCH_TERM: + term = term[:MAX_SEARCH_TERM] + nb = max(1, min(int(nb), MAX_SEARCH_RESULTS)) + start = max(0, int(start)) + q = urllib.parse.urlencode({"term": term, "type": type, "start": start, "nb": nb}) + data = SESSION.call("GET", "/search?" + q) + if not isinstance(data, dict): + data = {} + if data.get("error"): + raise DeemixError(_clean_error(f"Deezer search failed: {data.get('error')}")) + rows = [_compact_search_row(i, type) for i in (data.get("data") or [])[:nb]] + return _out({"type": type, "total": data.get("total"), "results": rows}) + + +@mcp.tool() +def deemix_add_to_queue(urls: str, bitrate: Optional[int] = None) -> str: + """Queue Deezer URLs for download. urls: one or several Deezer links, + space separated (album/track/artist/playlist), max 10 per call. + bitrate (optional) is a deezer-sdk TrackFormats enum id, NOT kbit/s: + 1=MP3 128kbps, 3=MP3 320kbps, 9=FLAC lossless. Omit to use Deemix's + own maxBitrate setting.""" + url_list = [u.strip() for u in (urls or "").split() if u.strip()] + if not url_list: + raise DeemixError("urls is empty") + if len(url_list) > MAX_URLS_PER_CALL: + raise DeemixError( + f"too many urls ({len(url_list)}); max {MAX_URLS_PER_CALL} per call" + ) + body: dict = {"url": " ".join(url_list)} + br = _valid_bitrate(bitrate) + if br is not None: + body["bitrate"] = br + res = SESSION.call("POST", "/addToQueue", body) + if not isinstance(res, dict) or res.get("result") is False: + errid = (res or {}).get("errid") if isinstance(res, dict) else "http" + raise DeemixError(_clean_error(f"addToQueue rejected (errid={errid!r})")) + data = res.get("data") or {} + obj = data.get("obj") + # Real Deemix behaviour: an unknown/invalid ID is accepted but resolves + # to zero items (obj == []) - nothing is queued. Report that honestly. + resolved = obj not in ([], None, "") + payload = { + "queued": resolved, + "bitrate_requested": br, + "bitrate_used": data.get("bitrate"), + "items": obj if isinstance(obj, (list, dict)) else str(obj), + } + if not resolved: + payload["warning"] = ( + "Deemix accepted the URL(s) but resolved NO items - the ID(s) " + "are probably invalid. Nothing was queued." + ) + return _out(payload) + + +@mcp.tool() +def deemix_queue() -> str: + """Current download queue with per-entry progress, size and errors (compact).""" + queue = SESSION.call("GET", "/getQueue") + entries = list((queue.get("queue") or {}).values()) + order = queue.get("queueOrder") or [] + by_id = {e.get("uuid"): e for e in entries} + ordered = [by_id[u] for u in order if u in by_id] + for e in entries: + if e.get("uuid") not in order: + ordered.append(e) + rows = [_compact_entry(e) for e in ordered[:MAX_QUEUE_ROWS]] + return _out( + { + "total": len(entries), + "failed": sum(1 for e in entries if e.get("failed")), + "rows": rows, + "truncated": len(entries) > MAX_QUEUE_ROWS, + } + ) + + +@mcp.tool() +def deemix_retry_download(uuid: str) -> str: + """Retry a failed queue entry (uuid from deemix_queue). Re-queues the whole album/track; existing files are not overwritten.""" + res = SESSION.call("POST", "/retryDownload", {"uuid": uuid}) + if not isinstance(res, dict) or res.get("result") is False: + raise DeemixError(_clean_error(f"retryDownload failed (errid={(res or {}).get('errid')!r})")) + return _out({"retried": uuid, "result": True}) + + +@mcp.tool() +def deemix_remove_from_queue(uuid: str) -> str: + """Cancel one queue entry by uuid (from deemix_queue).""" + q = urllib.parse.urlencode({"uuid": uuid}) + res = SESSION.call("POST", "/removeFromQueue?" + q) + if not isinstance(res, dict) or res.get("result") is False: + raise DeemixError(_clean_error("removeFromQueue failed - uuid unknown?")) + return _out({"removed": uuid, "result": True}) + + +@mcp.tool() +def deemix_remove_finished() -> str: + """Remove all finished (non-failed) entries from the queue. Does not touch files on disk.""" + res = SESSION.call("POST", "/removeFinishedDownloads") + if not isinstance(res, dict) or res.get("result") is False: + raise DeemixError(_clean_error("removeFinishedDownloads failed")) + return _out({"result": True}) + + +@mcp.tool() +def deemix_cancel_all() -> str: + """Cancel ALL active downloads in Deemix. Use only when the user explicitly asked to stop downloading.""" + res = SESSION.call("POST", "/cancelAllDownloads") + if not isinstance(res, dict) or res.get("result") is False: + raise DeemixError(_clean_error("cancelAllDownloads failed")) + return _out({"result": True}) + + +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/platform/mcp/install-tools.sh b/platform/mcp/install-tools.sh old mode 100755 new mode 100644 index 6e84ed1..9c95c2c --- a/platform/mcp/install-tools.sh +++ b/platform/mcp/install-tools.sh @@ -55,6 +55,11 @@ if [[ -s /etc/mike-ai/navidrome-mcp.env ]]; then else echo "Navidrome bleibt aus: Secret-Datei fehlt." fi +if [[ -s /etc/mike-ai/deemix-mcp.env ]]; then + profiles+=(--profile deemix) +else + echo "Deemix MCP bleibt aus: Konfigurationsdatei fehlt." +fi if [[ -s /etc/mike-ai/github-mcp.env ]] && \ grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then profiles+=(--profile github) diff --git a/platform/openwebui/install-filters.sh b/platform/openwebui/install-filters.sh old mode 100755 new mode 100644 index 57cd248..2eb1142 --- a/platform/openwebui/install-filters.sh +++ b/platform/openwebui/install-filters.sh @@ -38,12 +38,14 @@ rm -f "$volume_path/webui.db-wal" "$volume_path/webui.db-shm" navidrome_enabled=false [[ -s /etc/mike-ai/navidrome-mcp.env ]] && navidrome_enabled=true +deemix_enabled=false +[[ -s /etc/mike-ai/deemix-mcp.env ]] && deemix_enabled=true github_enabled=false if [[ -s /etc/mike-ai/github-mcp.env ]] && \ grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then github_enabled=true fi -python3 - "$db" "$FILTER_DIR" "$ACTION_DIR" "${OPENWEBUI_FILTER_OWNER_ID:-}" "$navidrome_enabled" "$github_enabled" "$MUA_MCP_ENV_FILE" <<'PY' +python3 - "$db" "$FILTER_DIR" "$ACTION_DIR" "${OPENWEBUI_FILTER_OWNER_ID:-}" "$navidrome_enabled" "$github_enabled" "$deemix_enabled" "$MUA_MCP_ENV_FILE" <<'PY' import json import copy import pathlib @@ -53,10 +55,11 @@ import time ( db, filter_dir, action_dir, requested_owner, navidrome_enabled_raw, - github_enabled_raw, mua_mcp_env_file, + github_enabled_raw, deemix_enabled_raw, mua_mcp_env_file, ) = sys.argv[1:] navidrome_enabled = navidrome_enabled_raw.lower() == "true" github_enabled = github_enabled_raw.lower() == "true" +deemix_enabled = deemix_enabled_raw.lower() == "true" con = sqlite3.connect(db) columns = {row[1] for row in con.execute("pragma table_info(function)")} required = { @@ -238,6 +241,13 @@ with con: "Websuche oder Audioausgabe auf dem KI-Host. Wegen des großen Werkzeugkatalogs " "nur bei Musikaufgaben aktivieren.", ), + "deemix-local": ( + "Deemix (bestehende Unraid-Instanz)", + "Durchsucht Deezer über die vorhandene Deemix-Instanz auf Unraid und " + "verwaltet deren Download-Queue. Keine zweite Deemix-Instanz. Schreibende " + "Queue-Aktionen nur auf ausdrücklichen Benutzerauftrag; Status und Suche " + "sind read-only.", + ), "github-local": ( "GitHub Repository (offiziell, read-only)", "Für Repository-Suche, echte Datei-Inhalte und gezielte " @@ -282,6 +292,8 @@ with con: match = "navidrome-local" elif "mike-ai-mcp-github" in url: match = "github-local" + elif "mike-ai-mcp-deemix" in url: + match = "deemix-local" elif "mike-ai-mcp-athena-operator" in url: match = "athena-operator-local" else: @@ -408,6 +420,30 @@ with con: } ) changed = True + if deemix_enabled and not any( + isinstance(connection, dict) + and ( + str(connection.get("url", "")).lower() + == "http://mike-ai-mcp-deemix:8000/mcp" + or str((connection.get("info") or {}).get("id", "")).lower() + == "deemix-local" + ) + for connection in connections + ): + name, description = descriptions["deemix-local"] + connections.append( + { + "url": "http://mike-ai-mcp-deemix:8000/mcp", + "path": "", + "type": "mcp", + "auth_type": "none", + "headers": None, + "key": "", + "config": {"enable": True, "access_grants": []}, + "info": {"id": "deemix-local", "name": name, "description": description}, + } + ) + changed = True if not any( isinstance(connection, dict) and (