Skip to content

DJ conversacional (Vera)

Capacidad de Vera (el agente OpenClaw en LXC 100) para actuar como DJ: el usuario pide música por vibra/mood/energía/duración o "parecido a X" por Telegram, y suena de verdad en un altavoz real de la casa — no una lista de texto. Ver también Música — Navidrome + AudioMuse-AI para el resto del stack musical.

Arquitectura

Vera (LLM, interpreta lenguaje natural)        Tablet familiar (JS, sin LLM)
   │  MCP stdio                                    │  fetch() HTTP, LAN-only
   ▼                                                ▼
server.py (MCP)                               http_api.py (FastAPI, :8090)
   │                                                │
   └──────────────► dj_core.py (lógica compartida) ◄┘
                        ├─► AudioMuse-AI  (192.168.0.141:8123, sin auth)
                        │     search_tracks · similar_tracks · find_path (progresión mood↔canción)
                        │     alchemy (blend ADD/SUBTRACT de canciones/moods) · mood_centroids
                        │     → devuelve IDs nativos de Navidrome/OpenSubsonic
                        └─► Music Assistant  (192.168.0.208:8095, JWT admin)
                              resuelve esos IDs directo a URI reproducible (`opensubsonic://track/…`)
                              y los manda a un altavoz real (Sonos/HomePod/AirPlay) — sin playlist
                              intermedia en Navidrome, sin lag de sync.

Vera (el LLM) hace toda la interpretación de lenguaje natural y encadena estos tools primitivos. La tablet no tiene LLM — su widget (js/views/dj.js, repo tablet-f1) llama directo a tools concretos (mood fijo, búsqueda por texto, play/stop/volumen/grupo). Ambos caminos comparten dj_core.py — cero lógica duplicada; un fix ahí arregla los dos consumidores a la vez.

Por qué la tablet no usa el JWT de Music Assistant directamente: ese token tiene control total de MA (reproducir, gestionar librería, grupos…). El JS de la tablet corre en un WebView que sirve HA (/local/, ver gotcha de seguridad en Cloudflare Access reference) — exponer ahí un secreto de ese calibre sería repetir el mismo problema que la fuga de config.local.js. En vez de eso, http_api.py corre solo en LAN (192.168.0.245:8090, UFW allow from 192.168.0.0/24, sin CF Tunnel/Access delante — mismo perfil de riesgo que AudioMuse-AI, que tampoco tiene auth) y es la tablet quien llama a ese HTTP plano, nunca al JWT real.

Tools (23)

Tool Qué hace
dj_list_players Lista altavoces reales + disponibilidad + volumen real
dj_search_track Busca una pista por texto (resuelve item_id)
dj_similar_tracks Canciones sónicamente parecidas a un item_id
dj_mood_centroids Moods disponibles + su índice (happy:0, relaxed:0…)
dj_find_path Progresión entre dos puntos (mood→canción, para "subiendo intensidad")
dj_alchemy Blend ADD/SUBTRACT de canciones/moods ("como X pero más oscuro")
dj_play Reproduce en un altavoz/grupo (mode: replace | enqueue)
dj_now_playing Estado real del altavoz (título/artista/álbum/uri por separado, no solo un string combinado)
dj_stop Para la reproducción
dj_set_volume Volumen 0-100 (escala nativa MA, no 0.0-1.0)
dj_group_players Crea un grupo persistente sincronizado de verdad
dj_ungroup_players Deshace un grupo (players/remove_group_player de MA) — antes solo desde la UI
dj_save_queue_as_playlist Guarda la cola actual de un altavoz como playlist real en Navidrome
dj_toggle_play Play/pausa (players/cmd/play_pause)
dj_next / dj_previous Saltar de pista dentro de la cola (players/cmd/next|previous)
dj_seek Saltar a un segundo concreto (players/cmd/seek) — no todos los proveedores lo soportan, ver gotcha
dj_queue_items Lista la cola REAL de un altavoz (título/artista/álbum/uri/queue_item_id)
dj_queue_jump Salta a la posición index de la cola
dj_queue_move Mueve una pista de la cola (pos_shift relativo)
dj_queue_remove Quita una pista de la cola (por queue_item_id, no por índice — ver gotcha)
dj_queue_clear Vacía la cola entera
dj_transfer_queue Mueve la cola ACTIVA de un altavoz a otro (nativo player_queues/transfer — posición incluida)

Playbook (resumen — ver TOOLS.md en el propio LXC para el detalle completo)

  1. "Ponme algo parecido a X"dj_search_track(X)dj_similar_tracksdj_play.
  2. "Como X pero más oscuro"dj_alchemy con el item_id de X + mood ADD/SUBTRACT (el id de mood SIEMPRE lleva :índice, nunca el nombre suelto).
  3. "N minutos subiendo de intensidad"dj_find_path (solo admite mood en UN extremo; el otro debe ser canción real — usar dj_alchemy para conseguir una canción-ancla si no hay semilla).
  4. Varios altavoces, misma salanunca dj_play uno a uno (desfasa/eco). dj_group_players(name, players) una vez, luego dj_play/dj_stop/ dj_set_volume apuntando al nombre del grupo. Comprobar con dj_list_players si ya existe uno para esa sala antes de crear otro.

Acceso / archivos

  • LXC 100 (clawdbot), SSH [email protected].
  • /root/clawd/scripts/mcp-dj/dj_core.py — lógica compartida (env vars, cliente AudioMuse, MASession, TOOL_SCHEMAS, dispatch()). Único sitio donde vive la implementación real de los 23 tools.
  • /root/clawd/scripts/mcp-dj/server.py — wrapper MCP fino para Vera (stdio).
  • /root/clawd/scripts/mcp-dj/http_api.py — wrapper HTTP fino para la tablet (FastAPI, GET /health, GET /tools, POST /call/{tool_name}).
  • /root/clawd/scripts/mcp-dj/run.sh / run_http.sh — mismas env vars: AUDIOMUSE_BASE, MA_WS (websocket, no HTTP), MA_TOKEN (JWT de larga duración, creado legítimo vía auth/loginauth/token/create, no extraído de la DB de auth hasheada de MA), OPENSUBSONIC_DOMAIN.
  • dj-http-api.service (systemd, enabled, MemoryMax=256M) — mantiene http_api.py corriendo en 0.0.0.0:8090. Desde 2026-07-26 el 8090 solo acepta a los dos Caddy (.247/.248) por UFW — los clientes entran por https://dj.monxas.casa (vhost manual en el Caddyfile, LAN-only por split-horizon DNS; ver la sección de revisión de arquitectura).
  • Registrado en /root/.openclaw/openclaw.jsonmcp.servers.dj (backup .bak-20260725-163605 antes del cambio original — diff verificado línea por línea, solo esa entrada nueva).
  • Log de llamadas: /var/log/dj-mcp/calls.jsonl (JSONL, tool/args/ok/error/elapsed_ms/ts, con via: "http" cuando la llamada viene de la tablet en vez de Vera).
  • Backups antes de cada edición: .bak-20260725-vol, .bak-20260725-groupfix, .bak-20260725-reviewfixes, .bak-20260725-refactor (el split en dj_core.py).

Widget de la tablet (Altavoces de casa)

Repo privado monxas/tablet-f1 (clon local ~/tablet-f1), desplegado a /config/www/tablet/ en HA (VM 171) vía sudo tee (nunca scp — el usuario SSH no es root y SFTP está desactivado, ver README del repo).

  • js/views/dj.js — vista nueva del swipe deck (patrón controles.js: tiles con estado + slider de volumen debounced + UI optimista). Tiles seleccionables (tap) para aplicar acciones a 1+ altavoces a la vez; agrupa automáticamente si hay 2+ seleccionados antes de reproducir.
  • css/dj.css — estilos, mismo lenguaje visual que controles.css.
  • js/config.local.jsDJ_API_BASE (URL LAN del backend, no es secreto).
  • Registrado en index.html: sheets array (carga css/dj.css), nueva <div class="view-page" id="dj-view"> dentro de views-wrapper (el swipe deck la recoge solo, swipe.js no necesita cambios — hace querySelectorAll('.view-page')), dj.init()/dj.update() cableados en boot() junto al resto de vistas normales (no es un takeover).
  • Verificado con Playwright headless contra el LAN IP de HA: boot sin errores, 12 tiles renderizados con datos reales de players/all, selección de tile, slider de volumen y búsqueda (dj_search_track vía AudioMuse) funcionando.

Reproductor de la tablet vía el mismo backend (2026-07-26)

El reproductor de música "de siempre" de la tablet (js/views/music.js — biblioteca completa Navidrome: buscar/artistas/playlists/favoritos) tenía un <audio> local que reproducía streaming directo de Navidrome en el altavoz de la propia tablet, con un <select> de salida "Aquí" / "Altavoces de casa" donde la segunda opción llevaba meses marcada "Pronto". Se decidió que el modo local no se usaba nunca en la práctica — en vez de mantener dos rutas de transporte en paralelo, se eliminó el modo local por completo.

Ahora music.js habla con el mismo dj-http-api que la sección "Altavoces de casa" — mismo backend, mismo djCall(), mismo LAN-only sin token expuesto. El <audio> de la tablet no se toca para nada; todo el transporte sale de dj_core.py vía 8 tools nuevos añadidos para esto:

  • Transporte (players/cmd/*): dj_toggle_play, dj_next, dj_previous, dj_seek.
  • Cola real (player_queues/*): dj_queue_items, dj_queue_jump, dj_queue_move, dj_queue_remove, dj_queue_clear. La vista "Cola" de la tablet pinta la cola genuina de Music Assistant (fetch en vivo), no una copia local en JS que se pudiera desincronizar de lo que suena de verdad.

dj_now_playing/dj_queue_items se enriquecieron con title/artist/ album/uri por separado (antes dj_now_playing solo daba un string combinado "Artista - Título", y dj_queue_items devolvía ese mismo string combinado como name en vez del título limpio del media_item). La uri (opensubsonic--xxx://track/<id>) trae el mismo item_id nativo de Navidrome que ya usa toda la biblioteca, así que sirve directo para coverUrl() / favoritos sin ida y vuelta extra. dj_list_players ahora también expone el volumen real (volume_level de MA) para poder sembrar el slider con el valor verdadero del altavoz al elegirlo, en vez de arrancar siempre en 0.8 por defecto.

Selector de altavoz: sustituye al viejo <select> estático — abre y lista dj_list_players en vivo, obligatorio elegir uno (no hay fallback local). Si se cambia de altavoz mientras suena algo, para el antiguo y relanza la misma cola en el nuevo.

Scrobble: sigue el mismo umbral de antes (50%/4min, ver más abajo), pero ahora se dispara sobre el elapsed_time real que reporta el altavoz (vía el poll de dj_now_playing cada 2s), no sobre audio.currentTime local — el cambio de pista que reinicia el estado de scrobble lo detecta el poll al ver que la uri cambió, no un evento 'ended'/'play' del <audio>.

Gotcha real: AirPlay no soporta seek — MA lo ignora en silencio

Confirmado contra el altavoz "Cocina" (AirPlay): supported_features no incluye "seek" (sí incluye "pause", que funciona perfecto). Al llamar a dj_seek/players/cmd/seek sobre él, Music Assistant no da ningún error — simplemente no mueve la posición. No hay detección de capacidad en la UI todavía, así que arrastrar la barra de progreso en un altavoz AirPlay no hace nada visible sin ningún aviso. Sonos debería soportarlo (no verificado en esta sesión). Si se quiere arreglar de verdad, habría que consultar supported_features del altavoz elegido y ocultar/ deshabilitar la barra de seek cuando no esté en la lista.

Gotcha real: dj_queue_remove por índice no siempre funciona — usar queue_item_id

Un intento de dj_queue_remove pasando un índice numérico como item_id_or_index falló con "MA error 3: Item 2 not found in queue" pese a que ese índice existía. Usando el queue_item_id real (string, viene de dj_queue_items) funcionó a la primera. music.js siempre usa queue_item_id, nunca índice, para dj_queue_remove/dj_queue_move.

Verificado extensamente con Playwright + comprobación directa contra Music Assistant (no solo confiando en la UI): reproducir desde "Buscar" arranca sonido real en el altavoz elegido (confirmado con curl independiente); pausa/reanuda mantiene la posición exacta del audio (probado con monitoreo segundo a segundo); mover el slider de volumen a un valor concreto aplica exactamente ese volumen real en Music Assistant; la vista de Cola muestra la cola genuina (16 pistas reales de un álbum, no una lista local inventada).

Commit pendiente en tablet-f1

Los archivos están desplegados y viven en el árbol de trabajo local del repo (rama develop, que ya tenía 6 commits propios del usuario sin pushear) — el commit a ese repo se dejó para que el usuario decida rama y mensaje, en vez de mezclarlo con su propio trabajo en curso.

Gotchas conocidos de este LXC — no repetirlos

  • openclaw update reinstala plugins externos — rompió todo el sistema una vez (5-jul) al externalizar un plugin. Evitar salvo necesidad clara.
  • doctor --fix sobrescribe ediciones a mano en disco. No ejecutar tras tocar openclaw.json/TOOLS.md/scripts a mano.
  • Es un asistente en uso diario — backup con sufijo antes de tocar, y verificar una capacidad previa (mcp-justeat-menu por ejemplo) sigue funcionando tras cualquier cambio.

Grupos sincronizados — el gotcha grande

Ver el detalle técnico completo (por qué el join de Home Assistant falla en silencio, y el comando nativo que sí funciona) en Música → Grupos sincronizados. Resumen para Vera/el dj:

players/cmd/group_many          → agrupación efímera, NO sobrevive reinicio
players/create_group_player      → PERSISTENTE (provider: sync_group) — usar esta

dj_group_players ya usa la vía persistente. Verificado con elapsed_time/elapsed_time_last_updated idénticos en los 3 altavoces del grupo (Sonos + 2× AirPlay), con canciones distintas y tras un ciclo completo de dj_stop.

dj_now_playing daba idle falso en altavoces agrupados (arreglado)

La implementación original consultaba player_queues/get con el player_id del propio altavoz — para un miembro de un grupo, esa cola está vacía (la cola real vive en el líder/grupo), así que reportaba "idle" aunque el altavoz sí estuviera sonando en sincronía. Fix: usar el estado nativo del reproductor (playback_state/current_media/elapsed_time de players/all), correcto tanto para solistas como para miembros de grupo. Verificado end-to-end tras el fix: state: playing + título real en un altavoz agrupado.

Code review 2026-07-25 — arreglos aplicados

Un agente de fondo revisó server.py a fondo (bugs, seguridad, funcionalidades ausentes) y también investigó si Music Assistant expone algún tipo de ajuste de audio. Backup previo: server.py.bak-20260725-reviewfixes / TOOLS.md.bak-20260725-reviewfixes.

Bugs arreglados:

  • Fuga de conexión WebSocket en fallo de auth: MASession.__aenter__ no cerraba el socket si el hello/auth fallaba (Python no llama a __aexit__ cuando __aenter__ lanza excepción). Ahora cierra el socket en el except antes de relanzar.
  • Sin reintentos pese al historial conocido de timeouts transitorios (ya vistos antes en volume_set): MASession.call() ahora acepta retries (default 1) y reintenta una vez tras TimeoutError con 0.5s de espera. Excepción explícita: la llamada a player_queues/play_media dentro de dj_play usa retries=0 — ya tiene su propio manejo (un timeout ahí puede significar que la reproducción sí arrancó, y reintentar podría duplicar la cola).
  • dj_set_volume no clampeaba el rango 0-100 por su cuenta (dependía solo del schema, que el SDK no fuerza en el servidor) — ahora hace max(0, min(100, vol)) explícito. Nota de la verificación: el cliente MCP oficial (mcp de Python) SÍ valida el inputSchema antes de enviar la llamada (rechaza volume=500 con "Input validation error" antes de que el servidor lo vea) — el clamp del servidor queda como defensa en profundidad para cualquier cliente que no valide.
  • Chequeo defensivo isinstance(p, dict) añadido en _ma_resolve_player y dj_list_players sobre la respuesta de players/all — mismo patrón de riesgo (lista-vs-dict) que ya había causado bugs en scripts de prueba de esta sesión, aunque nunca se disparó en server.py mismo.

Funcionalidades nuevas (las que el review consideró que sí merecían la pena — cola/skip/ shuffle/sleep-timer se descartaron por falta de evidencia de uso real en calls.jsonl):

  • dj_ungroup_players — usa players/remove_group_player de MA (comando encontrado en el bundle del frontend, /assets/index-*.js, junto a create_group_player en el mismo enum de comandos). Verificado end-to-end: crear grupo de test → ungroup → confirmar que desaparece de players/all.
  • dj_save_queue_as_playlist — lee player_queues/items del altavoz (cada item trae media_item.uri), crea una playlist real con music/playlists/create_playlist apuntando a OPENSUBSONIC_DOMAIN (no al provider builtin de MA — se probó también el atajo nativo player_queues/save_as_playlist, pero ese guarda en el provider interno de MA, no en Navidrome, así que no sirve para que la playlist sea visible desde Amperfy/otros clientes Subsonic) y le añade las pistas con music/playlists/add_playlist_tracks. Verificado end-to-end: reproducir → guardar → confirmar 1 pista guardada → limpiar el playlist de prueba con music/library/remove_item.

Investigado pero NO construido — ajuste de audio real disponible en Music Assistant:

  • Normalización de sonoridad (EBU R128): ya está activa por defecto en los altavoces (volume_normalization: true, target: -17 LUFS, visto en config/players/get). No requiere ningún cambio — ya funciona.
  • EQ/DSP paramétrico: existe un motor real (config/players/dsp/get / .../dsp/save, 6 tipos de filtro banda-a-banda + control de graves/medios/agudos + ganancia), aplicado por altavoz individual — hoy desactivado en los 6 altavoces y nunca usado. A diferencia del volumen proporcional entre altavoces de un grupo (no fiable, confirmado en sesiones previas), el EQ se configura por dispositivo individual, no como operación de grupo — sería fiable si se construye. No se implementó un tool dj_set_eq en esta pasada — es una capacidad nueva, no un arreglo, y no se pidió explícitamente construirla.

Code review 2026-07-25 (2ª pasada) — arreglos tras construir el widget de tablet

Un segundo agente revisó específicamente el backend HTTP nuevo y el widget de la tablet. Backups: dj_core.py.bak-20260725-codereviewfixes, http_api.py.bak-20260725-codereviewfixes, dj.js/dj.css con el mismo sufijo en la tablet.

  • CORS wildcard cerradohttp_api.py tenía allow_origins=["*"]. Confirmado como riesgo real (no solo teórico): a diferencia de AudioMuse-AI (solo lectura), este backend actúa sobre altavoces reales y muta estado persistente — con CORS abierto, cualquier página cargada por cualquier dispositivo ya en la LAN podía usar el propio navegador como pivote para mandar comandos sin que nadie lo note. Restringido a https://home.monxas.casa (el único origen real). El perímetro UFW seguía bien, el problema era el navegador-como-pivote dentro de la LAN, que CORS sí mitiga.
  • targetPlayers() en dj.js: el comentario prometía un fallback a "el único altavoz si solo hay uno" que el código nunca implementaba — con un solo altavoz disponible, el widget pedía seleccionar uno igualmente. Arreglado.
  • Grupos huérfanos en Music Assistant: dj_group_players creaba un grupo nuevo cada vez, sin comprobar si ya existía uno con los mismos miembros — tocar dos moods seguidos con la misma selección en la tablet dejaba grupos persistentes huérfanos acumulándose. Arreglado con detección-y-reutilización por membresía exacta (group_members/static_group_members). !!! warning "Gotcha real encontrado arreglando esto" Ambos campos de MA leen vacíos durante ~1s justo después de crear un grupo — incluso en la respuesta del propio create_group_player, no solo en una consulta posterior. Confirmado con pruebas directas contra dispatch() en el mismo proceso (descarta que fuera solo latencia de red/curl). El fix espera (poll corto, máx. ~1.8s) a que se asiente antes de devolver el resultado — si no, una llamada inmediatamente posterior con los mismos miembros no detectaba el grupo recién creado y creaba otro.
  • renderGrid() en dj.js: reconstruía todo el DOM del grid (innerHTML) en cada poll de 5s, a diferencia del patrón de controles.js (pintado in-place). Si alguien arrastraba el slider de volumen justo cuando caía el poll, el nodo desaparecía bajo el dedo. Arreglado con ensureTiles() (crea/ quita tiles solo cuando cambia el set de altavoces) + paintTile() (pinta in-place, respeta document.activeElement !== slider). Verificado con Playwright: el nodo del tile sobrevive 2 ciclos de poll completos.
  • Reentrancia en refreshPlayers(): sin guardia, cada poll de 5s abría 1+N conexiones WebSocket nuevas a MA sin esperar al ciclo anterior. Fix: flag _refreshing.
  • S.online ahora se usa: banner "backend no disponible" cuando falla el fetch (antes se trackeaba el estado pero no se mostraba nada).
  • Permisos: run_http.sh (contiene el JWT de MA en texto plano, igual que run.sh) endurecido a 700 — no se tocó run.sh (775, preexistente, en uso por Vera) por no arriesgar nada fuera del alcance de esta sesión.

Code review 2026-07-26 (3ª pasada) — condición de carrera concurrente + revisión completa de la tablet

Tercer code review, esta vez cubriendo dj_core.py/http_api.py con ojos frescos tras las dos rondas anteriores, y por primera vez una pasada completa de js/views/music.js (nunca revisado a fondo antes, solo se había identificado el bug de z-index). Backup: dj_core.py.bak-20260726-lockfix.

  • dj_group_players — condición de carrera concurrente (no solo secuencial): el fix de grupos huérfanos de la 2ª pasada cerraba el caso de llamadas repetidas una detrás de otra, pero dos llamadas VERDADERAMENTE simultáneas (p.ej. la tablet y Vera agrupando los mismos altavoces a la vez) podían no verse la una a la otra y crear dos grupos duplicados igualmente — server.py (subproceso por sesión de Vera) y http_api.py (proceso siempre vivo) son procesos de SO distintos, así que un asyncio.Lock en memoria no basta. Arreglado con un lock de fichero (fcntl.flock sobre /tmp/dj-group-create.lock, vía run_in_executor para no bloquear el event loop) que serializa el bloque completo de comprobar-y-crear. Verificado disparando 3 llamadas HTTP realmente concurrentes (curl ... & + wait) con los mismos miembros: antes habría creado 3 grupos, con el lock crea exactamente 1.
  • El resto de los 13 tools no comparten este patrón de "esperar convergencia de estado asíncrono + dedupe" — no hace falta el mismo tratamiento en ningún otro sitio.

Para la revisión completa de music.js (scrobbles falsos, auth, condiciones de carrera de navegación, etc.) ver Música — Navidrome + AudioMuse.

Revisión de arquitectura 2026-07-26 — el stack pasa de "funciona" a "sólido"

Revisión de arquitecto sobre todo lo construido (backend + 2 vistas de tablet + Vera), con mediciones reales en producción antes de tocar nada. Los números mandaron: 77-87 llamadas/min sostenidas al backend (91% polling de solo-lectura), 10.949 autenticaciones WebSocket contra MA en ~10h (cada llamada abría conexión+auth+close nuevas), picos de 122-182 conexiones/min. Cinco defectos de diseño reales, todos corregidos y verificados:

  1. Mixed content encubierto (el más grave): la página se sirve por HTTPS (home.monxas.casa) pero el API del DJ era http://IP:8090 — eso es mixed content y SOLO funcionaba porque Fully Kiosk lo tolera con un flag permisivo; en cualquier navegador normal, todos los widgets de música fallaban en silencio. Fix: https://dj.monxas.casa — vhost manual en el Caddyfile de los dos LXC de Caddy (NO vía tunnel-static.yaml: el sync de homelab-ctl lo habría publicado al túnel público), cert wildcard ya existente, resolución SOLO en LAN vía split-horizon DNS del Pi-hole, sin DNS público (verificado: desde internet → 404 del catch-all del túnel), y defensa extra en Caddy: peticiones con Cf-Connecting-Ip (= llegaron vía Cloudflare) → 403. UFW del 8090 cerrado a solo los dos Caddy (.247/.248).
  2. Tormenta de conexiones a MA: _SharedMAConnection en dj_core.py — UNA conexión WebSocket persistente por proceso, multiplexada por message_id (cada llamada registra un Future; una tarea lectora enruta respuestas), con reconexión automática. Medido: 20 llamadas → 1 autenticación en MA (antes 20). 12 llamadas concurrentes → 12/12 OK sin cruzarse respuestas. La interfaz async with MASession() se mantiene como vista sobre la compartida — cero cambios en los ~20 call-sites.
  3. AudioMuse bloqueaba el event loop: las llamadas al recomendador (urllib síncrono, timeout 30s) corrían DENTRO del event loop de FastAPI — y AudioMuse vive en bazzite, que se auto-apaga de noche: una búsqueda con el PC apagado congelaba TODO el transporte (ni play ni pausa) hasta 30s. Fix: asyncio.to_thread + timeout propio de 8s.
  4. N+1 en dj.js: cada tick pedía dj_now_playing por altavoz "powered" — y los 12 reportan powered: true siempre, así que eran 13 llamadas cada 5s. Fix: dj_list_players ahora devuelve playback_state/ now_playing/title/artist/uri de cada altavoz (players/all ya lo traía); dj.js hace 1 llamada por tick. _ma_resolve_player además usa players/get (un solo player) como fast-path en vez de pedir el inventario completo en cada resolución.
  5. Estado fantasma en music.js: S.queue/S.qIndex persistidos en localStorage se "relanzaban" al cambiar de altavoz — un snapshot potencialmente muerto de hace horas/días machacando la cola real que Vera/dj.js hubieran cambiado. Fix: eliminados; cambiar de altavoz usa el nuevo tool dj_transfer_queue (player_queues/transfer nativo de MA — mueve la cola real tal cual, posición incluida; verificado con reproducción real: Cocina→Habitación con la música siguiendo).

Y endurecimiento operativo: MemoryMax=256M en la unidad systemd (la caja es la de Vera, con historial real de OOM por cgroups), --no-access-log en uvicorn, el log de llamadas ya no registra los polls de solo-lectura exitosos (eran el 91% de ~10MB/día), logrotate semanal, chmod 700 run.sh (tenía 0775 con el JWT admin dentro), y frontend: djCall compartido en core.js (las dos vistas duplicaban el cliente y ya divergían), guarda de reentrancia en el poll de music.js, y degradación honesta de la UI (tras 3 fallos → "sin conexión" en vez de fingir que la última canción sigue sonando; altavoz desaparecido → olvidar y pedir elegir otro).

Decisión documentada — scrobble: verificado empíricamente que Music Assistant NO reporta nada a Navidrome (ni now-playing ni scrobble: pista completa reproducida vía MA → cero anotaciones en la DB). El scrobble de la tablet es por tanto el único que existe para reproducciones vía MA y se queda — con la limitación conocida de que scrobblea como user tablet lo que suene en su altavoz elegido, lo haya puesto quien lo haya puesto. Alternativa futura si molesta: mover el scrobble al backend (que ve todos los dj_play).

Decisión documentada — colocación: el control-plane sigue en LXC 100 (caja de Vera) a propósito: dj_core.py es UNA implementación compartida por import directo entre el MCP de Vera (que debe vivir ahí) y el HTTP de la tablet — moverlo a VM 208 partiría el código en dos hosts. El riesgo real (vecino con historial de OOM) se mitiga con el MemoryMax; openclaw update no toca la unidad systemd del DJ.

Migración pendiente: la tablet física ejecuta el JS viejo (apunta al 8090 directo) hasta que recargue el dashboard — hay una regla UFW TEMPORAL (allow from 192.168.0.150, comentada como tal) que mantiene el camino viejo vivo mientras tanto. Borrarla cuando la tablet haya recargado.

Verificación (cómo probarlo sin esperar a un mensaje real de Telegram)

Cliente MCP mínimo con el SDK mcp de Python (ya instalado en el LXC), lanzando server.py como subproceso con las mismas env vars que run.sh:

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# stdio_client(StdioServerParameters(command="/usr/bin/python3",
#     args=["/root/clawd/scripts/mcp-dj/server.py"], env={...}))
# → session.initialize() → session.call_tool("dj_play", {...})

Para confirmar sincronía real (no solo "ok" de la llamada): comparar elapsed_time de players/all en los altavoces del grupo, casi al mismo instante, con una canción nueva (si ya sonaba lo mismo antes de agrupar, un falso positivo es fácil — los altavoces pueden parecer sincronizados sin estarlo).