Add managed Deemix MCP integration

This commit is contained in:
Athena Operator
2026-08-25 10:26:37 +02:00
parent 4a260ae7d7
commit 1056784642
11 changed files with 1093 additions and 3 deletions
+2 -1
View File
@@ -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"
+13
View File
@@ -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
+469
View File
@@ -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())
+8
View File
@@ -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.
+5
View File
@@ -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:
+13
View File
@@ -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"]
+13
View File
@@ -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.
+20
View File
@@ -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:
+507
View File
@@ -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
View File
@@ -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)
+38 -2
View File
@@ -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 (