Add managed Deemix MCP integration
This commit is contained in:
@@ -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
|
||||
verspricht keine Überschreibung: Ob eine vorhandene Episode nach dem Download
|
||||
ersetzt wird, entscheiden Sonarrs Qualitätsprofil-, Upgrade- und Importregeln.
|
||||
|
||||
## Deemix MCP
|
||||
|
||||
`mcp-deemix` steuert ausschließlich die bereits vorhandene Deemix-Instanz auf
|
||||
Unraid unter `192.168.1.2:6595`. Es installiert kein zweites Deemix. Die
|
||||
root-only Datei `/etc/mike-ai/deemix-mcp.env` aktiviert das Compose-Profil.
|
||||
Hermes erreicht den Dienst intern als `http://mcp-deemix:8000/mcp`, OpenWebUI
|
||||
als `http://mike-ai-mcp-deemix:8000/mcp`. Im Single-User-Modus übernimmt der
|
||||
MCP die in Deemix gespeicherte Anmeldung nur kurzzeitig im Arbeitsspeicher;
|
||||
Secrets werden nicht in Tool-Ausgaben zurückgegeben. Nach Änderungen immer
|
||||
Handshake, `deemix_status`, eine begrenzte Suche und eine unveränderte Queue
|
||||
prüfen. Reinstall: Env-Beispiel nach `/etc/mike-ai/deemix-mcp.env` kopieren,
|
||||
Modus `0600` setzen und `platform/mcp/install-tools.sh` ausführen.
|
||||
|
||||
@@ -155,6 +155,26 @@ services:
|
||||
- /config:rw,noexec,nosuid,nodev,size=4m,mode=0700
|
||||
networks: [tools, egress]
|
||||
|
||||
mcp-deemix:
|
||||
<<: *tool-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.deemix
|
||||
image: mike-ai/mcp-deemix:1.0.0
|
||||
container_name: mike-ai-mcp-deemix
|
||||
profiles: [deemix]
|
||||
env_file:
|
||||
- ${DEEMIX_MCP_ENV_FILE:-/etc/mike-ai/deemix-mcp.env}
|
||||
environment:
|
||||
PORT: "8000"
|
||||
networks: [tools, egress]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
mcp-platform-context:
|
||||
<<: *tool-common
|
||||
build:
|
||||
|
||||
@@ -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
|
||||
echo "Navidrome bleibt aus: Secret-Datei fehlt."
|
||||
fi
|
||||
if [[ -s /etc/mike-ai/deemix-mcp.env ]]; then
|
||||
profiles+=(--profile deemix)
|
||||
else
|
||||
echo "Deemix MCP bleibt aus: Konfigurationsdatei fehlt."
|
||||
fi
|
||||
if [[ -s /etc/mike-ai/github-mcp.env ]] && \
|
||||
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
|
||||
profiles+=(--profile github)
|
||||
|
||||
Reference in New Issue
Block a user