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)¶
- "Ponme algo parecido a X" →
dj_search_track(X)→dj_similar_tracks→dj_play. - "Como X pero más oscuro" →
dj_alchemycon el item_id de X + mood ADD/SUBTRACT (el id de mood SIEMPRE lleva:índice, nunca el nombre suelto). - "N minutos subiendo de intensidad" →
dj_find_path(solo admite mood en UN extremo; el otro debe ser canción real — usardj_alchemypara conseguir una canción-ancla si no hay semilla). - Varios altavoces, misma sala → nunca
dj_playuno a uno (desfasa/eco).dj_group_players(name, players)una vez, luegodj_play/dj_stop/dj_set_volumeapuntando al nombre del grupo. Comprobar condj_list_playerssi 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íaauth/login→auth/token/create, no extraído de la DB de auth hasheada de MA),OPENSUBSONIC_DOMAIN.dj-http-api.service(systemd,enabled,MemoryMax=256M) — mantienehttp_api.pycorriendo en0.0.0.0:8090. Desde 2026-07-26 el 8090 solo acepta a los dos Caddy (.247/.248) por UFW — los clientes entran porhttps://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.json→mcp.servers.dj(backup.bak-20260725-163605antes 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, convia: "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 endj_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óncontroles.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 quecontroles.css.js/config.local.js→DJ_API_BASE(URL LAN del backend, no es secreto).- Registrado en
index.html:sheetsarray (cargacss/dj.css), nueva<div class="view-page" id="dj-view">dentro deviews-wrapper(el swipe deck la recoge solo,swipe.jsno necesita cambios — hacequerySelectorAll('.view-page')),dj.init()/dj.update()cableados enboot()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_trackví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 updatereinstala plugins externos — rompió todo el sistema una vez (5-jul) al externalizar un plugin. Evitar salvo necesidad clara.doctor --fixsobrescribe ediciones a mano en disco. No ejecutar tras tocaropenclaw.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-menupor 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 elexceptantes de relanzar. - Sin reintentos pese al historial conocido de timeouts transitorios (ya vistos antes en
volume_set):MASession.call()ahora aceptaretries(default 1) y reintenta una vez trasTimeoutErrorcon 0.5s de espera. Excepción explícita: la llamada aplayer_queues/play_mediadentro dedj_playusaretries=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_volumeno clampeaba el rango 0-100 por su cuenta (dependía solo del schema, que el SDK no fuerza en el servidor) — ahora hacemax(0, min(100, vol))explícito. Nota de la verificación: el cliente MCP oficial (mcpde Python) SÍ valida el inputSchema antes de enviar la llamada (rechazavolume=500con "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_playerydj_list_playerssobre la respuesta deplayers/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ó enserver.pymismo.
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— usaplayers/remove_group_playerde MA (comando encontrado en el bundle del frontend,/assets/index-*.js, junto acreate_group_playeren el mismo enum de comandos). Verificado end-to-end: crear grupo de test → ungroup → confirmar que desaparece deplayers/all.dj_save_queue_as_playlist— leeplayer_queues/itemsdel altavoz (cada item traemedia_item.uri), crea una playlist real conmusic/playlists/create_playlistapuntando aOPENSUBSONIC_DOMAIN(no al providerbuiltinde MA — se probó también el atajo nativoplayer_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 conmusic/playlists/add_playlist_tracks. Verificado end-to-end: reproducir → guardar → confirmar 1 pista guardada → limpiar el playlist de prueba conmusic/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 enconfig/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 tooldj_set_eqen 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 cerrado —
http_api.pyteníaallow_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 ahttps://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()endj.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_playerscreaba 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 propiocreate_group_player, no solo en una consulta posterior. Confirmado con pruebas directas contradispatch()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()endj.js: reconstruía todo el DOM del grid (innerHTML) en cada poll de 5s, a diferencia del patrón decontroles.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 conensureTiles()(crea/ quita tiles solo cuando cambia el set de altavoces) +paintTile()(pinta in-place, respetadocument.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.onlineahora 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 querun.sh) endurecido a700— 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) yhttp_api.py(proceso siempre vivo) son procesos de SO distintos, así que unasyncio.Locken memoria no basta. Arreglado con un lock de fichero (fcntl.flocksobre/tmp/dj-group-create.lock, víarun_in_executorpara 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:
- Mixed content encubierto (el más grave): la página se sirve por HTTPS
(
home.monxas.casa) pero el API del DJ erahttp://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íatunnel-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 conCf-Connecting-Ip(= llegaron vía Cloudflare) → 403. UFW del 8090 cerrado a solo los dos Caddy (.247/.248). - Tormenta de conexiones a MA:
_SharedMAConnectionendj_core.py— UNA conexión WebSocket persistente por proceso, multiplexada pormessage_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 interfazasync with MASession()se mantiene como vista sobre la compartida — cero cambios en los ~20 call-sites. - 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. - N+1 en dj.js: cada tick pedía
dj_now_playingpor altavoz "powered" — y los 12 reportanpowered: truesiempre, así que eran 13 llamadas cada 5s. Fix:dj_list_playersahora devuelveplayback_state/now_playing/title/artist/uride cada altavoz (players/all ya lo traía); dj.js hace 1 llamada por tick._ma_resolve_playerademás usaplayers/get(un solo player) como fast-path en vez de pedir el inventario completo en cada resolución. - Estado fantasma en music.js:
S.queue/S.qIndexpersistidos 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 tooldj_transfer_queue(player_queues/transfernativo 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).