"""Spotify: cerca e riproduce brani, album, artisti e playlist nell'app Spotify del Mac, controlla la riproduzione e mostra il titolo del brano in corso sul pannello LED iPIXEL a ogni cambio. Ricerca: API Web di Spotify con chiavi gratuite (developer.spotify.com, "client credentials", nessun login), impostate nella scheda Strumenti. Senza chiavi: accetta link/URI Spotify oppure apre la ricerca nell'app. """ from __future__ import annotations import base64 import json import subprocess import sys import threading import time import urllib.parse import urllib.request from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from avatar.settings import Settings # noqa: E402 _tok = {"v": "", "exp": 0.0} def _osa(script: str, timeout: int = 15) -> str: r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=timeout) if r.returncode != 0: err = (r.stderr or "").strip() if "-1743" in err: raise RuntimeError("Accesso a Spotify negato: consentilo in Impostazioni di Sistema > Privacy e sicurezza > Automazione.") raise RuntimeError(err.splitlines()[-1] if err else "errore AppleScript") return r.stdout.strip() def _play(uri: str) -> None: """play track con un nuovo tentativo: a volte Spotify impiega molto a rispondere al primo comando.""" for attempt in range(2): try: _osa(f'tell application "Spotify" to play track "{uri}"', timeout=30) return except subprocess.TimeoutExpired: if attempt: raise RuntimeError("Spotify non risponde: riprova tra poco") time.sleep(2) def _running() -> bool: return _osa('tell application "System Events" to (name of processes) contains "Spotify"') == "true" def _ensure_running() -> None: if not _running(): subprocess.run(["open", "-g", "-a", "Spotify"]) for _ in range(30): time.sleep(0.5) if _running(): time.sleep(2); return def _token() -> str: if _tok["v"] and time.time() < _tok["exp"]: return _tok["v"] s = Settings() cid, sec = str(s.get("spotify_client_id") or "").strip(), s.get_secret("spotify_client_secret") if not cid or not sec: return "" req = urllib.request.Request("https://accounts.spotify.com/api/token", data=b"grant_type=client_credentials", method="POST", headers={"Authorization": "Basic " + base64.b64encode(f"{cid}:{sec}".encode()).decode(), "Content-Type": "application/x-www-form-urlencoded"}) d = json.load(urllib.request.urlopen(req, timeout=15)) _tok.update(v=d["access_token"], exp=time.time() + int(d.get("expires_in", 3600)) - 60) return _tok["v"] def _search(q: str, tipo: str) -> dict | None: tok = _token() if not tok: return None url = "https://api.spotify.com/v1/search?" + urllib.parse.urlencode({"q": q, "type": tipo, "limit": 5, "market": "IT"}) d = json.load(urllib.request.urlopen(urllib.request.Request(url, headers={"Authorization": f"Bearer {tok}"}), timeout=15)) items = (d.get(tipo + "s") or {}).get("items") or [] items = [i for i in items if i] return items[0] if items else None def _uri_from_link(t: str) -> str: t = t.strip() if t.startswith("spotify:"): return t if "open.spotify.com/" in t: parts = urllib.parse.urlparse(t).path.strip("/").split("/") parts = [p for p in parts if not p.startswith("intl-")] if len(parts) >= 2: return f"spotify:{parts[0]}:{parts[1]}" return "" def riproduci(params: dict, ctx: dict) -> str: q = str(params.get("cosa") or "").strip() tipo = str(params.get("tipo") or "track").lower() tipo = {"brano": "track", "canzone": "track", "album": "album", "artista": "artist", "playlist": "playlist"}.get(tipo, tipo) if tipo not in ("track", "album", "artist", "playlist"): tipo = "track" if not q: return "Errore: dimmi cosa riprodurre." _ensure_running() uri, desc = _uri_from_link(q), q if not uri: try: it = _search(q, tipo) except Exception as err: return f"Ricerca Spotify fallita: {err}" if it is None and _token() == "": subprocess.run(["open", f"spotify:search:{urllib.parse.quote(q)}"]) return ("Ho aperto la ricerca in Spotify, ma per far partire la musica da sola mi servono le chiavi gratuite dell'API di Spotify " "(scheda Strumenti delle impostazioni). Intanto puoi premere play sul primo risultato.") if it is None: return f"Non ho trovato «{q}» su Spotify." uri = it["uri"] artisti = ", ".join(a["name"] for a in it.get("artists", [])[:2]) desc = f"{it['name']}" + (f" di {artisti}" if artisti else "") + ({"album": " (album)", "artist": " (artista)", "playlist": " (playlist)"}.get(tipo, "")) if uri.split(":")[1] in ("album", "artist", "playlist") and params.get("casuale"): _osa('tell application "Spotify" to set shuffling to true') _play(uri) _queue.clear() return f"In riproduzione su Spotify: {desc}." def controllo(params: dict, ctx: dict) -> str: az = str(params.get("azione") or "").lower() cmds = {"play": "play", "riprendi": "play", "pausa": "pause", "stop": "pause", "avanti": "next track", "successivo": "next track", "indietro": "previous track", "precedente": "previous track"} if az in cmds: _osa(f'tell application "Spotify" to {cmds[az]}') time.sleep(0.6) return f"Fatto ({az}). " + (in_ascolto({}, ctx) if az not in ("pausa", "stop") else "") if az in ("volume",): v = max(0, min(int(params.get("valore") or 50), 100)) _osa(f'tell application "Spotify" to set sound volume to {v}') return f"Volume di Spotify al {v}%." if az in ("casuale", "shuffle"): on = str(params.get("valore", "true")).lower() not in ("false", "0", "no") _osa(f'tell application "Spotify" to set shuffling to {"true" if on else "false"}') return "Riproduzione casuale " + ("attiva." if on else "disattivata.") return "Azioni: play, pausa, avanti, indietro, volume (valore 0-100), casuale." def now_playing() -> dict | None: if not _running(): return None out = _osa('tell application "Spotify" to if player state is playing then return (name of current track) & "|||" & (artist of current track) & "|||" & (id of current track)', timeout=8) if "|||" not in out: return None name, artist, tid = out.split("|||", 2) return {"titolo": name, "artista": artist, "id": tid} def in_ascolto(params: dict, ctx: dict) -> str: n = now_playing() if not n: return "Spotify non sta suonando." return f"Sta suonando: {n['titolo']} di {n['artista']}." # ── storico, coda e titolo sul pannello LED ────────────────────────────────── from avatar.settings import DATA_DIR # noqa: E402 ASCOLTI = DATA_DIR / "ascolti.jsonl" _queue: list[tuple[str, str]] = [] _led = {"t": 0.0} # (uri, descrizione) da suonare dopo il brano corrente def _registra(n: dict) -> None: try: ASCOLTI.parent.mkdir(parents=True, exist_ok=True) with open(ASCOLTI, "a", encoding="utf-8") as f: f.write(json.dumps({"ts": time.strftime("%Y-%m-%d %H:%M"), "titolo": n["titolo"], "artista": n["artista"], "id": n["id"]}, ensure_ascii=False) + "\n") except Exception: pass def _position() -> tuple[float, float]: out = _osa('tell application "Spotify"\nset p to (get player position)\nset d to (get duration of current track)\nreturn (p as text) & "|" & (d as text)\nend tell', timeout=8) a, b = out.replace(",", ".").split("|") return float(a), float(b) / 1000.0 def _watch() -> None: last = "" while True: time.sleep(3) try: n = now_playing() if not n: if _led["t"]: from avatar import ipixel if ipixel.panel is not None: ipixel.panel.clear_background() _led["t"] = 0 if _queue and _running() and _osa('tell application "Spotify" to player state', timeout=8) == "stopped": uri, _d = _queue.pop(0); _play(uri) last = "" if not n else last continue from avatar import ipixel p = ipixel.panel if n["id"] != last: last = n["id"] _registra(n) _led["t"] = 0 if p is not None and p.connected and p.settings.get("ipixel_musica", True) and time.time() - _led["t"] > 18: pos, dur = _position() data = ipixel.render_music(n["titolo"], n["artista"], pos, dur, p.size, 20) p.set_background(f"music:{n['id']}:{int(pos)}", data, True, 20) if _led.get("logged") != n["id"]: _led["logged"] = n["id"]; print(f"[spotify-led] pannello: {n['titolo']} - {n['artista']}", flush=True) _led["t"] = time.time() if _queue: pos, dur = _position() if dur > 0 and pos >= dur - 3.5: uri, _d = _queue.pop(0); time.sleep(max(0.0, dur - pos - 0.5)); _play(uri) except Exception as err: msg = str(err)[:200] if msg != _led.get("err"): _led["err"] = msg print(f"[spotify-led] {msg}", flush=True) def gusti(params: dict, ctx: dict) -> str: """Riepilogo dei gusti per consigliare: storico degli ascolti + preferenze musicali in memoria.""" righe = [] try: lines = ASCOLTI.read_text(encoding="utf-8").splitlines()[-400:] items = [json.loads(l) for l in lines if l.strip()] except Exception: items = [] if items: from collections import Counter art = Counter(i["artista"] for i in items); br = Counter(f"{i['titolo']} - {i['artista']}" for i in items) righe.append(f"Ascolti registrati: {len(items)} (dal {items[0]['ts'][:10]}).") righe.append("Artisti più ascoltati: " + ", ".join(f"{a} ({c})" for a, c in art.most_common(12))) righe.append("Brani più ascoltati: " + "; ".join(b for b, _ in br.most_common(10))) righe.append("Ultimi ascolti: " + "; ".join(f"{i['titolo']} - {i['artista']}" for i in items[-10:])) else: righe.append("Nessun ascolto registrato ancora (la registrazione parte ora).") try: from memory import memory_manager as mm r = mm.search_memory("musica dj genere artista brano ascolta producer", limit=8) if r: righe.append("Dalla memoria: " + " ".join(str(r).split())[:900]) except Exception: pass righe.append("Ora proponi tu 5-10 brani reali e coerenti con questi gusti (mescola noti e scoperte, evita di ripetere i più ascoltati) " "e, se l'utente vuole, falli partire con spotify_coda passando l'elenco 'Titolo - Artista'.") return "\n".join(righe) def coda(params: dict, ctx: dict) -> str: """Cerca e mette in fila una lista di brani: parte il primo, gli altri seguono a fine brano.""" raw = params.get("brani") or [] if isinstance(raw, str): raw = [x for x in raw.replace(";", "\n").splitlines()] brani = [str(x).strip() for x in raw if str(x).strip()][:25] if not brani: return "Errore: serve l'elenco dei brani." _ensure_running() trovati, mancanti = [], [] for b in brani: try: it = _search(b, "track") except Exception: it = None if it: trovati.append((it["uri"], f"{it['name']} - {', '.join(a['name'] for a in it.get('artists', [])[:2])}")) else: mancanti.append(b) if not trovati: return "Non ho trovato nessuno di questi brani su Spotify" + ("" if _token() else " (mancano le chiavi API)") + "." _queue.clear() _play(trovati[0][0]) _queue.extend(trovati[1:]) out = f"In riproduzione: {trovati[0][1]}. In coda: " + "; ".join(d for _u, d in trovati[1:]) if len(trovati) > 1 else f"In riproduzione: {trovati[0][1]}." return out + (f" Non trovati: {', '.join(mancanti)}." if mancanti else "") if Path(sys.argv[0]).name != "memory_mcp.py": # solo nell'app, non nel server MCP di Claude Code threading.Thread(target=_watch, daemon=True, name="spotify-led").start() TOOLS = [ {"name": "spotify_riproduci", "description": "Riproduce su Spotify un brano, album, artista o playlist cercandolo per nome (o da un link/URI Spotify). tipo: brano (default), album, artista, playlist. casuale=true per mescolare. Il titolo compare sul pannello LED.", "parameters": {"type": "object", "properties": {"cosa": {"type": "string"}, "tipo": {"type": "string", "enum": ["brano", "album", "artista", "playlist"]}, "casuale": {"type": "boolean"}}, "required": ["cosa"]}, "run": riproduci}, {"name": "spotify_controllo", "description": "Controlla Spotify: play, pausa, avanti, indietro, volume (valore 0-100), casuale (valore true/false).", "parameters": {"type": "object", "properties": {"azione": {"type": "string"}, "valore": {"type": "string"}}, "required": ["azione"]}, "run": controllo}, {"name": "spotify_gusti", "description": "Per consigliare musica: riepiloga i gusti dell'utente (artisti e brani più ascoltati su Spotify, ultimi ascolti, preferenze in memoria). Poi proponi tu brani adatti e, se vuole, falli partire con spotify_coda.", "parameters": {"type": "object", "properties": {}}, "run": gusti}, {"name": "spotify_coda", "description": "Fa partire una sequenza di brani su Spotify: brani = elenco 'Titolo - Artista'. Parte il primo, gli altri seguono automaticamente a fine brano. Usalo per i consigli e le selezioni a tema (es. 'musica per cenare', 'qualcosa di simile a…').", "parameters": {"type": "object", "properties": {"brani": {"type": "array", "items": {"type": "string"}}}, "required": ["brani"]}, "run": coda}, {"name": "spotify_in_ascolto", "description": "Dice quale brano sta suonando su Spotify.", "parameters": {"type": "object", "properties": {}}, "run": in_ascolto}, ]