[ethan] feat: add spotify integration. not completely reliable yet.

This commit is contained in:
Ethan Hanlon
2026-07-17 18:13:08 -07:00
parent 386300545c
commit 23182824d4
8 changed files with 342 additions and 2 deletions
@@ -26,6 +26,11 @@ CURATED_SHORTLIST: list[CuratedEntry] = [
"title": "Google Workspace",
"description": "Gmail, Calendar, Drive, Docs, Sheets, Slides; for reading/sending email, checking the user's schedule, and pulling context from their documents.",
},
{
"id": "Spotify",
"title": "Spotify",
"description": "Control playback, search music, and manage playlists; when the task involves music or audio."
},
{
"id": "Microsoft 365",
"title": "Microsoft 365",
@@ -0,0 +1,5 @@
"""Module-level entrypoint so `python -m backend.apps.spotify_mcp_shim` works."""
from backend.apps.spotify_mcp_shim.server import main
if __name__ == "__main__":
main()
+222
View File
@@ -0,0 +1,222 @@
from typing import Optional, Any, Dict
import psutil
import json
import urllib.request
import urllib.error
import os
# Bypass the OpenSwarm proxy for local Chrome CDP connections
os.environ["no_proxy"] = "*"
import subprocess
import time
def p_ensure_chrome_cdp():
"""Ensure Chrome is running with CDP on port 9223.
If not, we launch a dedicated profile so we don't conflict with the user's main Chrome,
and we leave it running in the background so music keeps playing after the script exits!
"""
try:
req = urllib.request.Request("http://127.0.0.1:9223/json/version")
with urllib.request.urlopen(req, timeout=0.5) as response:
if response.status == 200:
return True
except Exception:
pass
# CDP not responding. Let's auto-launch a dedicated Chrome instance!
profile_dir = os.path.expanduser("~/.openswarm/spotify_chrome_profile")
os.makedirs(profile_dir, exist_ok=True)
subprocess.Popen([
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"--remote-debugging-port=9223",
f"--user-data-dir={profile_dir}",
"--no-first-run",
"--no-default-browser-check"
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Wait for it to spin up
for _ in range(10):
time.sleep(0.5)
try:
req = urllib.request.Request("http://127.0.0.1:9223/json/version")
with urllib.request.urlopen(req, timeout=0.5) as response:
if response.status == 200:
return True
except Exception:
continue
return False
def play_track(track_name: str, artist: Optional[str]) -> Dict[str, Any]:
"""
Using the Playwright MCP, we'll:
1. Auto-launch a dedicated Chrome instance (with CDP) if it's not already running.
2. Navigate to Spotify. If this is the user's first time on this dedicated profile, they will need to log in.
3. Search for the song name.
4. Play the song!
"""
if not p_ensure_chrome_cdp():
return {
"is_error": True,
"is_human_intervention": False,
"message": "Failed to auto-start Chrome with remote debugging on port 9223."
}
import asyncio
def run_async_play_track():
async def inner():
from playwright.async_api import async_playwright
import urllib.parse
import base64
from io import BytesIO
from PIL import Image
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.agents.providers.registry import resolve_aux_model
settings = load_settings()
try:
model_id, _ = await resolve_aux_model(settings, preferred_tier="haiku")
client = get_anthropic_client_for_model(settings, model_id)
except Exception as e:
return {
"is_error": True,
"is_human_intervention": False,
"message": f"Could not load AI client for screenshot analysis: {e}"
}
try:
async with async_playwright() as p:
browser = await p.chromium.connect_over_cdp("http://127.0.0.1:9223")
context = browser.contexts[0]
page = None
for p_obj in context.pages:
if "spotify.com" in p_obj.url:
page = p_obj
break
if not page:
page = await context.new_page()
query = track_name
if artist:
query += f" {artist}"
search_url = f"https://open.spotify.com/search/{urllib.parse.quote(query)}"
await page.goto(search_url)
await page.wait_for_load_state("networkidle")
await asyncio.sleep(2) # Give elements time to render
prompt = f"""
I want to play the track '{query}' on Spotify. Look at this screenshot of the Spotify web UI.
Determine the state and return ONLY a valid JSON object. Do not include markdown formatting or backticks, just the raw JSON.
You must choose one of the following exact JSON structures:
1. If a login prompt or overlay is blocking the UI:
{{
"action": "human_intervention",
"message": "User needs to log in"
}}
2. If the track '{query}' is already playing (pause button is visible):
{{
"action": "done",
"message": "Successfully started playback"
}}
3. Otherwise, find the Play button for the top search result and return its exact center coordinates as percentages (0 to 100) of the image width and height:
{{
"action": "click",
"x_percent": 50.5,
"y_percent": 25.0,
"message": "Clicking play button"
}}
CRITICAL: Return strictly valid JSON. Double check your quotes and commas.
""".strip()
for _ in range(3):
screenshot_bytes = await page.screenshot()
img = Image.open(BytesIO(screenshot_bytes))
max_width = 1024
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
buf = BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=45)
b64_img = base64.b64encode(buf.getvalue()).decode("utf-8")
response = await client.messages.create(
model=model_id,
max_tokens=256,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": b64_img}}
]
}]
)
try:
# Parse JSON from response
resp_text = response.content[0].text.strip()
if resp_text.startswith("```json"):
resp_text = resp_text[7:-3].strip()
elif resp_text.startswith("```"):
resp_text = resp_text[3:-3].strip()
action_data = json.loads(resp_text)
if action_data["action"] == "human_intervention":
return {
"is_error": False,
"is_human_intervention": True,
"message": action_data.get("message", "Human intervention needed.")
}
elif action_data["action"] == "done":
return {
"is_error": False,
"is_human_intervention": False,
"message": action_data.get("message", "Task completed.")
}
elif action_data["action"] == "click":
vp_w = await page.evaluate("window.innerWidth")
vp_h = await page.evaluate("window.innerHeight")
click_x = (float(action_data["x_percent"]) / 100.0) * vp_w
click_y = (float(action_data["y_percent"]) / 100.0) * vp_h
await page.mouse.click(click_x, click_y)
await asyncio.sleep(2) # wait for playback to start
# loop continues to verify
except Exception as e:
return {
"is_error": True,
"is_human_intervention": False,
"message": f"Failed to parse AI response: {e}\nResponse was: {response.content[0].text}"
}
return {
"is_error": True,
"is_human_intervention": False,
"message": "AI failed to start playback after multiple attempts."
}
except Exception as e:
return {
"is_error": True,
"is_human_intervention": False,
"message": f"An error occurred: {str(e)}"
}
return asyncio.run(inner())
return run_async_play_track()
+71
View File
@@ -0,0 +1,71 @@
import sys
import json
from backend.apps.spotify_mcp_shim.tools import TOOLS
def p_send(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def p_err(text: str) -> dict:
return {"content": [{"type": "text", "text": f"Error: {text}"}], "isError": True}
def p_ok(payload) -> dict:
if isinstance(payload, str):
return {"content": [{"type": "text", "text": payload}]}
return {"content": [{"type": "text", "text": json.dumps(payload, indent=2, default=str)}]}
from backend.apps.spotify_mcp_shim.handlers import play_track
def handle_tool_call(name: str, args: dict) -> dict:
match name:
case "play_track":
return p_ok(play_track(**args))
return p_err(f"Unknown tool: {name}")
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method")
id_ = msg.get("id")
params = msg.get("params", {}) or {}
if method == "initialize":
p_send(id_, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "openswarm-spotify", "version": "1.0.0"},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
p_send(id_, {"tools": TOOLS})
elif method == "tools/call":
name = params.get("name", "")
args = params.get("arguments", {}) or {}
try:
p_send(id_, handle_tool_call(name, args))
except Exception as e:
p_send(id_, p_err(f"shim crashed: {e!r}"))
elif method == "ping":
p_send(id_, {})
elif id_ is not None:
p_send(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
+22
View File
@@ -0,0 +1,22 @@
"""MCP tool surface for Spotify: the things a logged-in human does.
Plays tracks. This is accomplished via a Playwright session, which may require
the user to manually input their credentials or solve a CAPTCHA.
"""
OBJ = "object"
TOOLS = [
{
"name": "play_track",
"description": "Opens a browser and plays the requested song.",
"inputSchema": {
"type": OBJ,
"properties": {
"track_name": {"type": "string"},
"artist": {"type": "string"}
},
"required": ["track_name"]
}
}
]
+1 -1
View File
@@ -152,7 +152,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
env["PYTHONPATH"] = (p_project_root + os.pathsep + existing_pp) if existing_pp else p_project_root
# The session-borrow social shims (reddit/x/tiktok) each run as a Python shim that borrows the user's live browser session via the backend's cookie bridge, so they need the localhost port + auth token, plus PYTHONPATH to import themselves.
if tool.name.lower() in {"reddit", "x", "tiktok"} and config.get("type") == "stdio":
if tool.name.lower() in {"reddit", "x", "tiktok", "spotify"} and config.get("type") == "stdio":
from backend.auth import get_auth_token
env = config.setdefault("env", {})
env["OPENSWARM_PORT"] = os.environ.get("OPENSWARM_PORT", "8324")
+3 -1
View File
@@ -24,4 +24,6 @@ swarm-analytics==0.1.1
tzlocal==5.3.1
# Test deps (pytest, pytest-asyncio) live in requirements-dev.txt — they
# never ship to production users and shaved ~3 MB / ~200 files off the
# Mac DMG when removed from the prod env.
# Mac DMG when removed from the prod env.
psutil==7.2.2
playwright==1.61.0
@@ -40,6 +40,19 @@ export const INTEGRATIONS: Integration[] = [
</svg>
),
},
{
id: 'spotify',
name: "Spotify",
description: 'Control playback, search tracks, and manage playlists.',
mcp_config: { type: 'stdio', command: 'python', args: ['-m', 'backend.apps.spotify_mcp_shim'] },
color: '#1DB954',
website: 'https://open.spotify.com',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<path fill="#1DB954" d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.063 17.188a.75.75 0 0 1-1.031.254c-2.826-1.727-6.375-2.121-10.562-1.185a.75.75 0 1 1-.318-1.46c4.58-1.0 8.53-.544 11.6 1.337a.75.75 0 0 1 .31 1.054zm1.437-3.656a.937.937 0 0 1-1.29.316c-3.24-1.98-8.175-2.56-11.97-1.43a.937.937 0 1 1-.546-1.79c4.28-1.31 9.66-.66 13.48 1.67a.937.937 0 0 1 .325 1.23zM18.5 8.5a1.125 1.125 0 0 1-1.558.378c-3.63-2.45-9.72-2.67-13.0-1.49A1.125 1.125 0 0 1 4.06 5.6c3.8-1.34 10.22-1.08 14.06 1.77A1.125 1.125 0 0 1 18.5 8.5z"/>
</svg>
),
},
{
id: 'tiktok',
name: 'TikTok',