Add managed Deemix MCP integration
This commit is contained in:
+2
-1
@@ -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-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-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-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"
|
DO_NOT_TRACK: "true"
|
||||||
SCARF_NO_ANALYTICS: "true"
|
SCARF_NO_ANALYTICS: "true"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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
|
auf Port 8085 sind im alten Zustand breiter gebunden als im Zielsystem. Beim
|
||||||
Neuaufbau werden beide auf localhost begrenzt; Clients verwenden ausschließlich
|
Neuaufbau werden beide auf localhost begrenzt; Clients verwenden ausschließlich
|
||||||
den Router auf Port 8081.
|
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.
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ mcp_servers:
|
|||||||
timeout: 300
|
timeout: 300
|
||||||
connect_timeout: 30
|
connect_timeout: 30
|
||||||
supports_parallel_tool_calls: false
|
supports_parallel_tool_calls: false
|
||||||
|
deemix:
|
||||||
|
url: "http://mcp-deemix:8000/mcp"
|
||||||
|
timeout: 300
|
||||||
|
connect_timeout: 30
|
||||||
|
supports_parallel_tool_calls: false
|
||||||
unraid:
|
unraid:
|
||||||
url: "${MUA_MCP_URL}"
|
url: "${MUA_MCP_URL}"
|
||||||
headers:
|
headers:
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
Ticket ist auch daran gebunden. Der MCP löscht keine vorhandenen Dateien und
|
||||||
verspricht keine Überschreibung: Ob eine vorhandene Episode nach dem Download
|
verspricht keine Überschreibung: Ob eine vorhandene Episode nach dem Download
|
||||||
ersetzt wird, entscheiden Sonarrs Qualitätsprofil-, Upgrade- und Importregeln.
|
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.
|
||||||
|
|||||||
@@ -155,6 +155,26 @@ services:
|
|||||||
- /config:rw,noexec,nosuid,nodev,size=4m,mode=0700
|
- /config:rw,noexec,nosuid,nodev,size=4m,mode=0700
|
||||||
networks: [tools, egress]
|
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:
|
mcp-platform-context:
|
||||||
<<: *tool-common
|
<<: *tool-common
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -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://<unraid>: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")
|
||||||
Executable → Regular
+5
@@ -55,6 +55,11 @@ if [[ -s /etc/mike-ai/navidrome-mcp.env ]]; then
|
|||||||
else
|
else
|
||||||
echo "Navidrome bleibt aus: Secret-Datei fehlt."
|
echo "Navidrome bleibt aus: Secret-Datei fehlt."
|
||||||
fi
|
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 ]] && \
|
if [[ -s /etc/mike-ai/github-mcp.env ]] && \
|
||||||
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
|
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
|
||||||
profiles+=(--profile github)
|
profiles+=(--profile github)
|
||||||
|
|||||||
Executable → Regular
+38
-2
@@ -38,12 +38,14 @@ rm -f "$volume_path/webui.db-wal" "$volume_path/webui.db-shm"
|
|||||||
|
|
||||||
navidrome_enabled=false
|
navidrome_enabled=false
|
||||||
[[ -s /etc/mike-ai/navidrome-mcp.env ]] && navidrome_enabled=true
|
[[ -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
|
github_enabled=false
|
||||||
if [[ -s /etc/mike-ai/github-mcp.env ]] && \
|
if [[ -s /etc/mike-ai/github-mcp.env ]] && \
|
||||||
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
|
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
|
||||||
github_enabled=true
|
github_enabled=true
|
||||||
fi
|
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 json
|
||||||
import copy
|
import copy
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -53,10 +55,11 @@ import time
|
|||||||
|
|
||||||
(
|
(
|
||||||
db, filter_dir, action_dir, requested_owner, navidrome_enabled_raw,
|
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:]
|
) = sys.argv[1:]
|
||||||
navidrome_enabled = navidrome_enabled_raw.lower() == "true"
|
navidrome_enabled = navidrome_enabled_raw.lower() == "true"
|
||||||
github_enabled = github_enabled_raw.lower() == "true"
|
github_enabled = github_enabled_raw.lower() == "true"
|
||||||
|
deemix_enabled = deemix_enabled_raw.lower() == "true"
|
||||||
con = sqlite3.connect(db)
|
con = sqlite3.connect(db)
|
||||||
columns = {row[1] for row in con.execute("pragma table_info(function)")}
|
columns = {row[1] for row in con.execute("pragma table_info(function)")}
|
||||||
required = {
|
required = {
|
||||||
@@ -238,6 +241,13 @@ with con:
|
|||||||
"Websuche oder Audioausgabe auf dem KI-Host. Wegen des großen Werkzeugkatalogs "
|
"Websuche oder Audioausgabe auf dem KI-Host. Wegen des großen Werkzeugkatalogs "
|
||||||
"nur bei Musikaufgaben aktivieren.",
|
"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-local": (
|
||||||
"GitHub Repository (offiziell, read-only)",
|
"GitHub Repository (offiziell, read-only)",
|
||||||
"Für Repository-Suche, echte Datei-Inhalte und gezielte "
|
"Für Repository-Suche, echte Datei-Inhalte und gezielte "
|
||||||
@@ -282,6 +292,8 @@ with con:
|
|||||||
match = "navidrome-local"
|
match = "navidrome-local"
|
||||||
elif "mike-ai-mcp-github" in url:
|
elif "mike-ai-mcp-github" in url:
|
||||||
match = "github-local"
|
match = "github-local"
|
||||||
|
elif "mike-ai-mcp-deemix" in url:
|
||||||
|
match = "deemix-local"
|
||||||
elif "mike-ai-mcp-athena-operator" in url:
|
elif "mike-ai-mcp-athena-operator" in url:
|
||||||
match = "athena-operator-local"
|
match = "athena-operator-local"
|
||||||
else:
|
else:
|
||||||
@@ -408,6 +420,30 @@ with con:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
changed = True
|
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(
|
if not any(
|
||||||
isinstance(connection, dict)
|
isinstance(connection, dict)
|
||||||
and (
|
and (
|
||||||
|
|||||||
Reference in New Issue
Block a user