Música — Navidrome + AudioMuse-AI + Music Assistant¶
Stack de streaming, curación e IA musical de la casa: servidor Navidrome, pipeline de metadatos (Lidarr → beets), letras sincronizadas, normalización de volumen, smart playlists, radio por IA local (AudioMuse-AI en GPU), un mapa visual de la biblioteca (galaxia sónica), orquestación multiroom real (Music Assistant), un reproductor en la tablet familiar y un DJ conversacional para Vera (ver DJ conversacional).
Para la adquisición de música (Lidarr / Soulseek / soularr) ver Media Stack → Music Acquisition. Este doc cubre todo lo que pasa después de tener los ficheros en el NAS.
Estado (2026-07-26)
9.743 pistas · 776 álbumes · 3.252 artistas · ~200 GB (69% FLAC lossless real). 0 duplicados de
artista/álbum, 8.643 letras .lrc (7.111 sincronizadas), ReplayGain al 100%,
7 smart playlists + 10 automáticas de AudioMuse (clustering), análisis AudioMuse
completo (776/776 álbumes). Cliente iOS: Cassette. Multiroom real vía
Music Assistant con grupos sincronizados persistentes (Sonos+AirPlay).
Componentes¶
| Componente | Host | Puerto | Rol |
|---|---|---|---|
| Navidrome | VM 208 (Docker) | 4533 | Servidor Subsonic/OpenSubsonic. music.monxas.casa. v0.63.2 |
| beets | VM 208 (Docker) | — | Re-tag/organización de metadatos contra MusicBrainz |
| Lidarr | VM 208 (Docker) | 8686 | Gestor de biblioteca (dueño de la estructura de ficheros) |
| AudioMuse-AI | Bazzite .141 (podman) |
8123 | Análisis sónico + radio/similares/mood (GPU RTX 3080) |
| Music Assistant | VM 208 (Docker) | 8095 | Orquestación multiroom real (Sonos/AirPlay/HomePod) |
| Galaxia sónica | VM 208 (Docker/nginx) | 4544 | Mapa visual WebGL2 de la biblioteca por sonido |
| NAS | TerraMaster .237 |
NFS | Almacenamiento /Volume1/Media/music → /mnt/nfs_media/music |
| Cliente | iPhone | — | Cassette (TestFlight) — Subsonic nativo con Instant Mix |
| Cliente | Tablet familiar | — | Reproductor embebido en el dashboard HA (ver más abajo) |
- Navidrome admin:
monxas/alfagann· Lidarr API key enconfig.xml. - AudioMuse / Music Assistant:
monxas/alfagannSSH al Bazzite y al 208 (puerto 22; elwsl-ai:2222 del ssh config está obsoleto).
Arquitectura¶
graph TB
subgraph NAS["NAS .237 (NFS)"]
MUSIC[(/Volume1/Media/music
214GB FLAC · monxas:monxas)]
end
subgraph VM208["VM 208 — Docker"]
LIDARR[Lidarr :8686
dueño de la estructura]
BEETS[beets
re-tag MusicBrainz]
ND[Navidrome :4533
ND_PID_ALBUM=folder]
end
subgraph BAZ["Bazzite .141 — podman + RTX 3080"]
AM[AudioMuse-AI :8123
pod: postgres+redis+flask+worker]
end
subgraph EDGE["Edge"]
CF[Cloudflare Tunnel + Access]
end
CLIENT[Cassette / Amperfy
iPhone]
LIDARR -->|import| MUSIC
BEETS -->|write tags| MUSIC
MUSIC -->|NFS ro| ND
ND -->|/rest stream| AM
AM -->|getSimilarSongs plugin| ND
ND --> CF --> CLIENT
AM -.->|IP interna .208:4533| ND
Pipeline de biblioteca¶
Regla de oro: Navidrome agrupa por TAGS, no por carpetas. Mover carpetas NO cambia la agrupación. Y Lidarr es el dueño de la estructura de ficheros.
Adquisición (Soulseek/torrent) → Lidarr importa a /data/music (con carpeta de álbum)
↓
beets re-taggea contra MusicBrainz (albumartist, MBID, año, feat.) [move:no, write:yes]
↓
NAS /mnt/nfs_media/music (monxas:monxas)
↓
Navidrome escanea (read-only) → agrupa por PID=folder
NUNCA hacer dedup manual de ficheros en biblioteca Lidarr
Lidarr trackea rutas concretas. Si mueves/borras ficheros que él trackea, los
re-importa (incluso re-descarga) → reaparecen los duplicados. La causa raíz de
"álbum = nombre del artista" era Lidarr con renameTracks:false volcando
pistas sueltas en la raíz del artista. Fix (config Lidarr):
renameTracks:true + standardTrackFormat con {Album Title} ({Release Year})/…
+ RenameFiles por artista. Verificar qué copia trackea Lidarr con
GET /api/v1/trackfile?artistId=N antes de tocar.
Lidarr no mueve los .lrc al renombrar
Un RenameFiles deja los .lrc huérfanos aunque importExtraFiles:true. Hay
que relocar los .lrc con el mapeo existingPath→newPath de
GET /api/v1/rename?artistId=N tras el rename.
Configuración de Navidrome¶
Variables clave en el compose (/home/monxas/stacks/media/compose.yaml, no versionado):
ND_PID_ALBUM: folder # una carpeta = un álbum (ignora tags inconsistentes)
ND_SCANNER_PURGEMISSING: full # purga SOLO en full scans (era 'always': con el
# historial de caídas NFS, un escaneo en mal momento
# podía purgar la biblioteca entera — ver auditoría 26-jul)
ND_COVERARTPRIORITY: "cover.*, folder.*, front.*, embedded"
ND_LASTFM_ENABLED: "true" # arte de artista + biografías + getTopSongs
ND_LASTFM_APIKEY: ${LASTFM_API_KEY} # SOPS secrets/media.sops.yaml
ND_LASTFM_SECRET: ${LASTFM_SHARED_SECRET}
ND_ENABLETRANSCODINGCONFIG: "true" # transcode al vuelo (datos móviles)
# ND_ENABLESHARING: default ON — NO desactivar (ver gotcha)
PID=folder: el token es albumartistid, NO albumartist
ND_PID_ALBUM=folder funciona. Pero usar el token inválido albumartist
dispara los álbumes a 2× (hace PID por fichero). Cambiar ND_PID_ALBUM
requiere scan --full (los PIDs se cachean en la DB); a veces hizo falta
rebuild de la DB.
scan --full (forzar re-lectura)¶
El startScan de la API es incremental — NO detecta cambios de tags ni borrados.
Tras cualquier re-tag de beets o cambio de ficheros, hay que forzar el scan --full.
Acceso y auth (CF Access)¶
Tres Access apps (creadas vía CF API, patrón n8n-webhook-bypass):
| App | Path | Policy | Motivo |
|---|---|---|---|
music-rest-bypass |
music.monxas.casa/rest |
bypass everyone | API Subsonic sin login → apps móviles |
music-share-bypass |
music.monxas.casa/share |
bypass everyone | imágenes de artista (artistImageUrl = /share/img/<jwt>) |
music (dominio) |
music.monxas.casa |
allow PocketID | UI web protegida |
GOTCHA CRÍTICO — imágenes de artista en Amperfy
Las fotos de artista van por artistImageUrl = /share/img/<jwt>, no por
/rest. Sin el bypass de /share, CF Access devuelve 302 al login y ninguna
foto de artista carga. El JWT lo firma Navidrome, así que el bypass es seguro.
Regla: cualquier cliente Subsonic con artistImageUrl necesita /share + /rest.
Letras (LRCLIB)¶
8.643 pistas con .lrc (7.111 sincronizadas con timestamps, 1.532 planas).
Bajadas con un fetcher a LRCLIB (gratis, sin API key) que escribe un .lrc
sidecar al lado de cada audio.
Permisos de los .lrc — el gotcha que rompe las letras
El contenedor beets corre como root → los .lrc nacen root:root 660 y
Navidrome (uid 1000) no puede leerlos. Tras cualquier fetch/move de letras:
Navidrome sirve .lrc bajo demanda
Los .lrc externos NO se indexan en la columna media_file.lyrics (esa solo
guarda letras embebidas). Navidrome los sirve al vuelo por getLyricsBySongId
con timestamps. Verificar con:
curl ".../rest/getLyricsBySongId.view?...&id=<track>" → structuredLyrics con "start":ms.
Limpieza de basura de scraping ("N Contributors… Lyrics", "…Embed") en letras
embebidas: FLAC=VorbisComment lyrics; MP3=frame USLT (no la clave lyrics);
M4A=\xa9lyr.
ReplayGain (normalización de volumen)¶
Nivela el volumen entre pistas (la biblioteca era muy "loud": rango de 32 dB,
mediana de ajuste −9 dB). Usar rsgain, NO el plugin de beets (su backend
ffmpeg da status 254 en beets 2.12 aunque ffmpeg tenga ebur128).
docker exec beets sh -c "apk add --no-cache rsgain" # Alpine
docker exec beets rsgain easy -m 4 /music # track_gain + album_gain, in-place
Reanudable y seguro
rsgain easy preserva el owner (monxas). Para reanudar si se corta, iterar
por carpeta saltando álbumes ya taggeados (el primer track ya tiene
replaygain_track_gain). GOTCHA medidor: rsgain taggea MP3 como frames
RVA2, no TXXX — un checker que solo mire TXXX los cuenta como sin RG.
Smart playlists¶
7 playlists .nsp (JSON auto-actualizable) en /mnt/nfs_media/music/Smart Playlists/.
Aparecen en Navidrome tras scan --full y en cualquier cliente.
| Playlist | Regla |
|---|---|
| 🎲 Descubrimiento | playCount = 0, random 100 |
| 💿/📀/📻 Años 90/2000/2010 | inTheRange year, random 150 |
| ✨ Novedades 2020s | inTheRange year [2020,2029] |
| 🔥 Más escuchadas | gt playCount 0, sort playCount |
| 🕰️ Sin tocar | gt playCount 0 + notInTheLast lastPlayed 90 |
Formato NSP: {"name": "...", "all": [{"op": {"field": val}}], "sort": "...", "limit": N}.
Operadores: is/gt/lt/inTheRange/inTheLast/notInTheLast/isMissing, sort: random.
AudioMuse-AI (radio por IA local)¶
Analiza el audio real de cada pista (huella sónica, no tags) para dar
similares por sonido, Instant Mix/radio y mood playlists — self-hosted, sin APIs
externas. Integra con Navidrome vía sonicSimilarity (0.62+).
Despliegue (Bazzite .141, podman + GPU)¶
Pod audiomuse con 4 contenedores + workers, imagen
ghcr.io/neptunehub/audiomuse-ai:latest-nvidia. Web: http://192.168.0.141:8123
(puerto 8123 porque SillyTavern ocupa el 8000).
podman pod create --name audiomuse -p 8123:8000
# postgres15 + redis + flask + worker(s), cada uno en el pod
podman run -d --pod audiomuse --name am-worker \
--device nvidia.com/gpu=all --security-opt=label=disable \ # <-- clave
-e SERVICE_TYPE=worker -e PER_SONG_MODEL_RELOAD=false \
-e POSTGRES_HOST=127.0.0.1 -e REDIS_URL=redis://127.0.0.1:6379/0 \
ghcr.io/neptunehub/audiomuse-ai:latest-nvidia
GPU rootless + SELinux (Bazzite)
Además de --device nvidia.com/gpu=all hace falta --security-opt=label=disable
o falla con Failed to initialize NVML: Insufficient Permissions. CDI en
/etc/cdi/nvidia.yaml (generado con nvidia-ctk cdi generate).
Persistencia: podman generate systemd --new --files --name audiomuse +
loginctl enable-linger monxas + systemctl --user enable pod-audiomuse.
Config media server: por el Setup Wizard web (no env). Se guarda en la tabla
music_servers (Postgres). Apuntar a la IP interna http://192.168.0.208:4533
(no el dominio público — evita el túnel CF para 10k descargas):
podman exec -i am-postgres psql -U audiomuse -d audiomusedb -c \
"update music_servers set creds='{\"url\":\"http://192.168.0.208:4533\",\"user\":\"monxas\",\"password\":\"alfagann\"}' where server_type='navidrome';"
Lanzar análisis (incremental — resume, los embeddings persisten en Postgres):
curl -X POST http://127.0.0.1:8123/api/analysis/start \
-H "Content-Type: application/json" -d '{"num_recent_albums":0,"top_n_moods":5}'
# num_recent_albums:0 = todos. Progreso: /api/last_task ; cancelar: /api/cancel/<task_id>
Rendimiento del análisis — lecciones¶
El cuello NO es la GPU ni el tamaño de datos
Diagnóstico tras varias pruebas (rango real de resultados):
| Cambio | Resultado |
|---|---|
| 6 workers (baseline) | ~26 pistas/min |
| 12 workers | peor — cada worker carga su copia del modelo → VRAM 9.4/10GB thrashing |
| Transcode a opus (10× menos datos) | peor (1.0 vs 2.0 alb/min) — en LAN gigabit bajar FLAC ya es gratis; transcodear añade latencia ffmpeg |
PER_SONG_MODEL_RELOAD=false |
✅ +20% (~31 pistas/min) — no recarga el modelo por canción |
Óptimo: ~6 workers + PER_SONG_MODEL_RELOAD=false. El default true recarga
el modelo cada pista (pensado para GPUs pequeñas); con 10GB de VRAM, ponerlo en
false (modelo residente, recicla cada 20) es la única palanca que batió el baseline.
Uso desde Cassette¶
- Instant Mix / radio de una canción (icono ✨) → va por el plugin de Navidrome
AudioMuse-AI-NV-plugin(getSimilarSongs). Funciona en cualquier cliente Subsonic, sin configurar nada en la app. - Mood playlists por sonido → Cassette → Ajustes → AudioMuse → URL
http://192.168.0.141:8123. Sin conectar, las moods usan tags (más burdas).
Galaxia sónica (mapa visual)¶
Visualización interactiva de la biblioteca: cada pista es una estrella posicionada por cómo suena (embeddings de AudioMuse, no tags). Vive en http://192.168.0.208:4544/ (LAN, sin auth — misma zona de confianza que el resto de dashboards del homelab).
AudioMuse Postgres (embedding + score + track_server_map)
→ galaxy_project.py (dentro de am-worker: umap/sklearn)
· L2-normalize → UMAP(cosine) → 2D
· KMeans(k=26) → 26 "regiones sónicas" (color por género dominante)
→ web/galaxy_data.json (~2.2 MB)
→ web/index.html — WebGL2 self-contained (sin CDNs), covers+audio directo de Navidrome
Fuente en el repo: services/sonic-galaxy/ (README.md, galaxy_project.py,
deploy.sh, web/). Actualizar tras crecer el análisis: regenerar el JSON
dentro de am-worker (reusa sus libs), copiarlo a web/galaxy_data.json y
./deploy.sh (rsync + recreate contenedor nginx). El nginx sirve web/
de solo lectura, así que un scp del JSON solo basta para refrescar datos.
Por qué UMAP propio y no map_projection_data de AudioMuse
Esa tabla nativa estaba vacía. Se calcula la proyección dentro del propio
contenedor am-worker para reusar exactamente sus mismas librerías/versión,
en vez de reinstalar UMAP/sklearn en otro sitio.
Music Assistant (multiroom real)¶
Orquesta los altavoces físicos de la casa (Sonos, HomePods/AirPlay, Apple TV) sobre el catálogo de Navidrome — Navidrome sigue siendo la biblioteca; MA es la capa de reproducción/colas/grupos. Contenedor Docker en VM 208 (no add-on de HAOS — la VM de HA solo tiene ~1.3 GB libres; 208 lo absorbe con ~150 MB).
image: ghcr.io/music-assistant/server:latest
network_mode: host # obligatorio para mDNS de Sonos/AirPlay
mem_limit: 1536m # sin swap
Web UI: http://192.168.0.208:8095 (admin monxas/alfagann). Conectado a
Navidrome por IP interna (http://192.168.0.208:4533, no el dominio
público — evita el túnel CF). Integración en HA: Ajustes → Dispositivos y
servicios → Music Assistant (suele autodescubrirse vía zeroconf).
mDNS duplicado: puede aparecer descubierta DOS VECES en HA
El 208 tiene ~25+ redes Docker (network_mode: host expone el anuncio mDNS
por todas ellas). HA puede mostrar dos tarjetas "Music Assistant" — son el
mismo servidor, no dos instancias. Configurar solo la que muestre
192.168.0.208:8095; ignorar la otra.
Puerto 8095/8097 en colisión
jellyfin-share ya usaba 8097 en este host — mover el stream de MA a
8100 si hace falta, y net.ipv4.igmp_max_memberships=1024 persistente
(/etc/sysctl.d/99-music-assistant-mdns.conf) o los ~30 bridges Docker
agotan el mDNS y AirPlay/Chromecast no se descubren.
Auditoría de la capa MA/altavoces (2026-07-26) — todo verificado, sin cambios necesarios¶
Auditoría de arquitecto sobre MA 2.9.9 con evidencia en vivo. Resultado: limpio — cero cambios necesarios en esta capa. Lo verificado:
- Sin grupos huérfanos: tras un día entero de tests que crearon/deshicieron
grupos sync,
players/allmuestra soloDormitorio Grupo(el intencional). La disciplina de limpieza + el fix de reutilización de grupos aguantaron. - 20 providers, todos
available: true(opensubsonic, airplay, sonos, sendspin, lrclib para letras, lastfm_recommendations, loudness_analysis…).
Matriz de capacidades por altavoz (afecta a qué botones tienen sentido en cualquier UI — la tablet hoy no la consulta, mejora futura):
| Altavoz | Provider | seek | pausa | cola nativa |
|---|---|---|---|---|
| Despacho, Dormitorio | sonos | ✅ | ✅ | ✅ |
| Cocina, Salón, Habitación, Apple TV | airplay | ❌ | ✅ | ❌ |
| Macs, LG TV, HA Voice, Monxas AirPlay | universal_player | ❌ | ❌ | ❌ |
Los universal_player son objetivos "lanzar y parar" — ni pausa. El seek de
la barra de progreso solo funciona de verdad en los dos Sonos.
Normalización de volumen: VERIFICADA aplicándose (y una falsa alarma útil)
Config: activada en los 11 altavoces, target -17 LUFS, estrategia
global fallback_dynamic. Durante la auditoría, streamdetails mostraba
volume_normalization_mode: measurement_only — que parece "solo mide, no
corrige". Leyendo el código del pipeline de MA dentro del contenedor
(controllers/streams/audio.py): ese modo calcula target - medido y lo
aplica como filtro volume=XdB de ffmpeg — es la ganancia ESTÁTICA basada
en la medición previa (del provider loudness_analysis), no un no-op. Se
comprobó gain real de -8.89 dB aplicándose a una pista de -8.11 LUFS.
fallback_dynamic = si una pista aún no está medida, usa loudnorm
dinámico hasta que lo esté. Todo correcto — no tocar.
Dato colateral del mismo probe: Sonos recibe FLAC 24-bit/44.1 y AirPlay recibe ALAC 16-bit/44.1 (límite del protocolo AirPlay, no un error de config).
Cassette — verificación server-side completa (2026-07-26)¶
Todo lo que Cassette necesita, probado desde el camino público real
(music.monxas.casa/rest con el bypass de CF Access, credenciales tablet):
ping→ ok (Navidrome 0.63.2).getOpenSubsonicExtensions→transcodeOffset, formPost, songLyrics, indexBasedQueue, transcoding, playbackReport, sonicSimilarity— la extensión sonicSimilarity es la integración AudioMuse que Cassette usa para su Instant Mix sónico.getTopSongs→ devuelve resultados reales (la feature por la que se eligió Cassette sobre Amperfy).getSimilarSongs2→ 5 resultados (Instant Mix operativo).getArtistInfo2→ URL de imagen bajo/share/img/<jwt>y la descarga da HTTP 200 sin cookies de CF (el bypass de/sharefuncionando — sin él, los móviles no ven fotos de artista).
Auditoría Navidrome + biblioteca + backups (2026-07-26) — hallazgos y arreglos¶
Auditoría de arquitecto con evidencia (DB, logs, permisos, cron). Lo bueno
primero: DB sana (integrity_check=ok, 9.743 pistas / 776 álbumes / 3.252
artistas, missing=0), raíz NFS correcta, 8.643 .lrc bien, las 25 playlists
presentes (las 7 "smart" con 0 canciones son evaluación perezosa, no un bug).
Arreglado en el momento:
ND_SCANNER_PURGEMISSING=always→full(el hallazgo más peligroso): conalways, un escaneo que pillara el NFS caído (historial real: nfsd D-state 14-jul, root:root 21-jul) purgaría permanentemente pistas + anotaciones + entradas de playlists, sin papelera. Confullsolo purga en escaneos completos deliberados, nunca en los rápidos del watcher.- 1.894 ficheros
root:rooten la biblioteca → 0, incluidas las 207 carátulas con modo 600 ilegibles para Navidrome (verificado antes/después con la que fallaba en el log:Lil Wayne/Rebirth/cover.jpg→ legible). Causa: beets corre como root con umask 077. Pendiente: chown al final de cada pasada de beets (hoy solo existe el fix-script de movies/tvshows). - Cuarentena oculta
.quarantine-dupes-20260723(71M) movida fuera del árbol de la biblioteca a_music_quarantine_20260724/hidden-quarantine-20260723/. - 3 playlists
TEST_*_DELETE_MEborradas (restos de los tests del DJ:library_sync_backde MA no propagó el borrado a Navidrome — gotcha). - Script de backup v4: el run del 25-jul murió sin dejar rastro (sin
línea de fin, tarball 2G más pequeño) y el del 19-jul quedó truncado tras
un "Terminated" — un backup truncado que no avisa es peor que uno fallido.
v4 añade: trap que loguea si lo matan, snapshot transaccional de las DBs
sqlite (navidrome.db + library.db de MA) antes del tar, y
gzip -tdel tarball al final. Última copia buena conocida: 24-jul (18G, verificada por rc); la del 19 la purga la retención de 7 días sola. - Warmer de imágenes de artista: el fix Last.fm del 23-jul rellena
perezosamente (solo al abrir cada artista) — 3.243/3.252 seguían grises.
/home/monxas/scripts/warm-artist-images.shrecorregetArtistInfo2para todos a 2/s (one-shot, re-ejecutable).
Backlog documentado (decisión pendiente, no ejecutado):
- Segunda copia de backup fuera del NAS: hoy única copia en el mismo NAS
que la biblioteca (
/mnt/nfs_media/backups/appdata, retención 7d). El appdata de Navidrome Y de Music Assistant SÍ están dentro (verificado) — el gap es el destino único. Candidato natural: PBS. - ~15-25 grupos de duplicados reales restantes (query título+álbum+
artista da 30 grupos; muestreo: algunos falsos positivos tipo interludios,
otros reales tipo
05 Clocks.flac+05 Clocks__1.flac). Pasada fina de dedup como la del 24-jul, otro día. ND_CONFIGFILEapunta a un fichero inexistente (arranca igual — cosmético).- Ruido en logs a vigilar: plugin AudioMuse devolviendo 404 esporádicos y
ráfagas HTTP 429 en
getCoverArtdel cliente "galaxy".
AudioMuse — postura de disponibilidad (documentada, sin cambios)¶
AudioMuse vive en bazzite (sobremesa .141), que puede apagarse. Estado
auditado: vivo, clustering al día (run 43, kmeans con 85 clusters), y el
boolean del auto-apagado nocturno (input_boolean.bazzite_auto_apagado_noche)
está off por defecto — el riesgo real es solo un apagado manual. Tras la
revisión de arquitectura del backend, ese riesgo está acotado: las llamadas a
AudioMuse corren fuera del event loop con timeout de 8s, así que con bazzite
apagado fallan la búsqueda/moods/similares del DJ (con error limpio) pero el
transporte (play/pausa/volumen/colas) sigue funcionando. Migrar AudioMuse a
VM 208 eliminaría el riesgo pero pierde la GPU (RTX 3080) que usa el análisis
de biblioteca — decisión consciente de dejarlo donde está.
Grupos sincronizados (multi-altavoz, misma sala)¶
Reproducir la misma canción llamando por separado a cada altavoz NO sincroniza
de verdad — cada uno arranca con su propia latencia de red y se oye desfasado/
con eco. La sincronía real cruza protocolos (Sonos nativo + AirPlay 2) vía el
puente Sendspin (protocolo abierto de la Open Home Foundation, reloj maestro
compartido — por eso el elapsed_time de altavoces agrupados sale idéntico
hasta el último decimal, verificado con players/all).
media_player.join de Home Assistant falla EN SILENCIO para grupos cruzados
El servicio nativo de HA (media_player.join) puede devolver success: true
y aun así no crear ningún grupo real en Music Assistant — verificado
consultando el estado nativo (group_members/synced_to del objeto
players/all, no el atributo que muestra HA, que puede quedar en un estado
optimista/stale). La vía que sí funciona es la API nativa de MA:
players/cmd/group_many {target_player, child_player_ids} # agrupación EFÍMERA (no sobrevive reinicio)
players/create_group_player {provider:"sync_group", name, members, dynamic:false} # PERSISTENTE (recomendado)
config/players con provider: sync_group,
tipo "group" — sobrevive a reinicios y aparece listada igual que cualquier
otro altavoz. Comando encontrado leyendo el JS del propio frontend de MA
(assets/index-*.js, función createPlayerGroup), no documentado en la
web pública de Music Assistant.
Verificar sincronía de verdad (no solo 'suena lo mismo')
Comparar elapsed_time + elapsed_time_last_updated de players/all en los
altavoces del grupo casi al mismo instante. Si son idénticos, están realmente
sincronizados; si difieren en segundos, cada uno arrancó por su cuenta (falso
positivo típico si los altavoces YA estaban reproduciendo lo mismo antes de
agruparlos — probar siempre con una canción nueva para confirmar).
Reproductor en la tablet familiar¶
Cliente Subsonic completo (artistas/álbumes/playlists/búsqueda/favoritos)
embebido en el dashboard kiosk de Home Assistant (/homeassistant/www/tablet/,
Fire Tablet + Fully Kiosk). Sigue el patrón de "capas takeover" ya existente en
ese dashboard (#now-playing, #f1-live, #justeat-live).
- Entrada: botón flotante (FAB) abajo-derecha, siempre visible en home. Sin
reproducción, es un botón circular; sonando, muta a una "pill" con carátula
girando + control inline ("modo mixto" — convive con el resto del dashboard,
no lo tapa). Se oculta bajo
body.any-takeover(F1/TV/JustEat). - Usuario Navidrome dedicado
tablet(no admin) — credenciales enjs/config.local.js(gitignored). - Despliegue: SSH
ha-addon+sudo tee(nuncascpa este host).
Cambio de fondo (2026-07-26): el reproductor SIEMPRE suena en un altavoz real
Antes el <audio> de la tablet reproducía localmente (streaming directo de
Navidrome), con la idea de un futuro "modo redirigir a altavoces de casa"
(el <select> de salida llevaba meses marcado "Pronto"). En la práctica
ese modo local nunca se usaba — se decidió eliminarlo del todo en vez de
mantener dos rutas de transporte en paralelo.
Ahora js/views/music.js nunca toca el <audio> de la tablet — todo
el transporte (play/pausa/siguiente/anterior/volumen/seek/cola) pasa por
el mismo backend LAN-only de Music Assistant que ya usa la sección
"Altavoces de casa" (dj-http-api en LXC 100), vía
polling de dj_now_playing cada 2s en vez de eventos del <audio> local
(play/pause/timeupdate/ended). El selector de salida (antes
"Aquí"/"Altavoces de casa · Pronto") ahora lista altavoces reales
(dj_list_players) y es obligatorio elegir uno — no hay fallback local.
Se añadieron 8 tools nuevos a dj_core.py para esto (22 en total):
dj_toggle_play/dj_next/dj_previous/dj_seek (transporte,
players/cmd/*) y dj_queue_items/dj_queue_jump/dj_queue_move/
dj_queue_remove/dj_queue_clear (cola real de MA, player_queues/* —
la vista "Cola" de la tablet pinta la cola genuina de Music Assistant, no
una copia local que se pudiera desincronizar). Ver el detalle completo,
incluida la verificación contra MA real, en
DJ conversacional (Vera).
Gotcha real: AirPlay (altavoz "Cocina") no soporta seek en Music
Assistant — confirmado en supported_features; MA lo ignora en silencio
sin dar error, así que arrastrar la barra de progreso ahí no hace nada
visible. Sonos debería soportarlo. No hay detección de capacidad en la UI
todavía — el seek simplemente puede no funcionar según el altavoz elegido.
Gotcha del dashboard (no de música): el swipe se deshacía a los pocos segundos, no a los 20s reales (2 iteraciones)
refreshAll() (el orquestador de takeovers, poll cada 5s) forzaba
#now-playing/#f1-live a display:block en CUALQUIER poll mientras
hubiera contenido activo, sin mirar window.__browsing — el swipe hacia
otras vistas (con su propio idle timer de 20s en swipe.js,
initNowPlayingNav/initF1LiveNav) se deshacía casi al instante en el
siguiente poll de 5s, dejando apenas tiempo real para interactuar antes
de que la vista "currently watching"/F1 volviera.
1er intento: guard browsingAwayFromThis solo en el bucle que pone
el.style.display de los takeovers — arregló el "vuelve a los 5s", pero
dejó una regresión real: el cálculo de n (cuántos takeovers hay
activos, usado para decidir si mostrar views-wrapper) seguía leyendo
active en crudo, que no sabía nada de window.__browsing. Resultado:
con algo reproduciéndose y el usuario haciendo swipe, #now-playing
quedaba oculto (por el guard nuevo) PERO views-wrapper TAMBIÉN se
ocultaba (porque n>=1 seguía siendo cierto) — pantalla en negro a
los ~5s de cada swipe, reportado en producción.
Fix real: effectiveActive (active Y NOT
browsing-away-de-ese-id-concreto) sustituye a active para TODO lo que
decide qué se pinta — clases show-*/any-takeover/multi-takeover,
el display de los propios takeovers, y si se muestra views-wrapper.
active en crudo ya no se usa para ninguna decisión visual, solo como
entrada a las reglas de supresión (F1 apaga now-playing, etc.) antes de
calcular effectiveActive. Verificado con Playwright en los puntos
exactos donde antes fallaba (~6s y ~12s tras el swipe): views-wrapper
se mantiene visible, nunca ambos en display:none a la vez. Reaparece
todo con normalidad a los ~20-21s reales.
Gotcha del dashboard (no de música): SyntaxError recurrente por caché de ../core.js
Las 15 vistas del dashboard importaban ../core.js de forma estática
(import {...} from '../core.js', sin ?v=), mientras que core.js
SOLO se carga con cache-bust real desde index.html (su propia lista de
import(\./js/core.js?v=${v}`)) — son dos URLs de módulo distintas
para el mismo archivo. Una vista fresca (con?v=nuevo) podía acabar
corriendo contra uncore.jsviejo servido desde la caché HTTP del
WebView bajo la URL sin versionar. Síntoma real: al añadirwasRecentRestart()acore.js, el dashboard caía a la pantalla de
error conSyntaxError: ../core.js does not provide an export named
'wasRecentRestart'— y no era la primera vez que pasaba algo así
("muchas veces sale esto" según el usuario), cualquier cambio anterior a
los exports decore.jspudo causar lo mismo silenciosamente. **Fix**:
las 15 vistas ahora hacen el mismo import dinámico versionado que ya
usabanmusic.js/dj.jsparaconfig.local.js(reutilizan el?v=deimport.meta.urlde la propia vista). Si se añade una vista nueva:
seguir ese mismo patrón, nuncaimport ... from '../core.js'` a secas.
Gotcha del dashboard (no de música): reinicio de HA confundido con F1/Just Eat en directo
f1-live.js/justeat-live.js "lingerean" el takeover unos minutos tras
el último cambio del sensor (15 min F1, 5 min Just Eat), para no parpadear
si hay un hueco breve de datos durante un evento real. Un reinicio de HA
deja esos sensores en unavailable casi en el mismo instante que
sensor.uptime (el arranque de HA) — el linger no distinguía eso de un
evento real en curso, así que un simple reinicio dejaba F1/Just Eat
encendidos en la tablet 15-20 minutos sin motivo. Fix: wasRecentRestart()
en core.js compara el last_changed del sensor contra sensor.uptime
(±60s de tolerancia) antes de lingerear. Confirmado con el caso real:
sensor.f1_session_status cambió a las 20:20:26, sensor.uptime marcaba
arranque a las 20:20:23 — 3 segundos de diferencia, reinicio, no carrera.
Bug arreglado (2026-07-26): el reproductor abierto tapaba un takeover de F1/JustEat
#music-player tiene z-index:100 (el más alto salvo el modal de gesto);
los takeovers reales (F1/JustEat) van por debajo. #music-fab ya se
ocultaba durante body.any-takeover (CSS puro), pero #music-player no
tenía esa misma regla — si alguien lo dejaba abierto y luego arrancaba F1
en directo o llegaba un pedido de Just Eat, se quedaba tapado.
Fix: misma receta que ya usaba el FAB, aplicada también al player —
body.any-takeover #music-player { display: none !important; } en
css/music.css. El !important hace falta para ganarle al
style.display inline que ponen open()/close() en JS. No se toca el
estado de JS (sección, cola, music-open) — al terminar el takeover el
player reaparece tal cual se dejó, sin que el usuario tenga que reabrirlo.
Verificado con Playwright: abierto sin takeover → visible; con
any-takeover activo → oculto; takeover terminado → reaparece solo.
Code review completo del reproductor (2026-07-26)¶
Primera pasada de revisión completa de js/views/music.js (742→900 líneas,
antes solo se había identificado el bug de z-index de arriba). Backups en la
tablet: music.js.bak-20260726-codereview. Todo verificado con Playwright
headless contra la tablet real antes de darlo por bueno.
- Scrobbles falsos (bug real, reproducible):
loadCurrent()llamaba ascrobble()incondicionalmente, sin mirar siautoplayeratrue. Restaurar la cola en pausa al arrancar el dashboard (init()→loadCurrent(false)) o quitar de la cola la pista en pausa (removeFromQueue()→loadCurrent(!audio.paused)) registraba un "reproducido" en Navidrome sin que sonara ni un segundo. Fix: el "now playing" (Subsonicsubmission=false) solo se manda en el eventoplayreal del<audio>; el scrobble de verdad (submission=true) solo al cruzar un umbral entickSeek()— 50% reproducido o 4 minutos, la convención Subsonic/Last.fm que antes no existía en absoluto. Verificado: recargar con cola en pausa → cero peticiones a/rest/scrobble; reproducir de verdad → 1 peticiónsubmission=falseinmediata, ningunasubmission=trueprematura. - Auth Subsonic en claro → esquema salteado.
p=<contraseña>por query string (quedaba en cada URL de stream/portada/scrobble, expuesto en logs de acceso y caché de red del WebView) sustituido port=md5(pass+salt)& s=salt. MD5 propio en vanilla JS (sin dependencias, restricción del archivo) verificado contra los 6 vectores de prueba del RFC 1321 antes de usarlo. Verificado end-to-end: búsqueda, stream y portadas funcionando con el nuevo esquema, cero peticiones/rest/fallidas. - Condición de carrera navegando:
renderArtists/renderPlaylists/renderStackTopno comprobaban si su resultado seguía vigente tras elawait— a diferencia dedoSearch, que sí lo hacía. Abrir el artista A y volver atrás rápido a abrir B podía sobrescribir B con los datos de A si la respuesta de A llegaba tarde. Fix:navTokenincrementado en cada navegación real (setSection/pushStack/MUS.home/MUS.crumb), comprobado antes de pintar. - Cascada de saltos ante fallo de red: sin freno, un fallo del stream saltaba a la siguiente canción en 400ms, que fallaba igual, etc. — podía recorrer la cola entera en segundos. Para tras 3 fallos consecutivos.
toggleStar/scrobble viacall(): usabanfetch()directo, que no detecta que Subsonic responda HTTP 200 constatus:"failed"en el JSON — un fallo real (permisos, id inválido) se trataba como éxito. Ahora usancall(), que sí valida el body.- Fuga de memoria:
_rowCtxStore(contexto de listas para reproducir en orden) crecía sin límite — cota LRU de 24 entradas, mismo patrón que_blobCacheencore.js. - Control de volumen ausente en "Sonando": en tablets ≤900px,
.mus-bar- right(volumen + selector de salida) se oculta por CSS con el comentario "control desde Sonando" — perorenderNowPlaying()nunca lo implementaba, dejando el volumen inaccesible ahí. Añadido, reutilizando la lógica de arrastre ya existente (wireVolumeControl(), extraída deattachSeek()). Gotcha real al implementarlo: los IDs elegidos al principio (np-vol-track/np-vol-fill) ya existían ennow-playing.js/index.htmlpara el OTRO "ahora suena" (el de vídeo/Jellyfin, no música) — el choque de nombres hacía quegetElementByIddevolviera el elemento equivocado (oculto, tamaño cero). Renombrado amus-npv-track/mus-npv-fill. - Código muerto eliminado (dos líneas sin efecto real, restos de una implementación anterior).
En dj.js (repasado por tercera vez): el desplegable de búsqueda ahora se
cierra al tocar fuera (antes solo al vaciar el input), y S.selected ya no
se queda con player_ids fantasma de altavoces que desaparecen.
Clientes iOS¶
Verificado en el código de cada repo qué soporta "top songs por artista"
(getTopSongs) — Reddit recomienda Arpeggi pero NO lo tiene:
| App | OSS | Top-songs artista | Instant Mix | Instalación |
|---|---|---|---|---|
| Cassette ⭐ | ✅ | ✅ | ✅ (+ AudioMuse) | TestFlight |
| flo | ✅ | ✅ | — | App Store |
| Substreamer | ✅ | ✅ | — | App Store |
| play:Sub | ❌ | ✅ | ✅ | App Store (€4.99) |
| Amperfy | ✅ | ❌ | — | App Store |
| Arpeggi / iSub / Yuzic | ✅ | ❌ | — | varía |
Amperfy: refrescar tras cambios server-side
Amperfy cachea. Tras re-tag/rebuild: borrar caché + resync o quitar y re-añadir el servidor (los IDs cambian con un rebuild de la DB).
Gotchas críticos (resumen)¶
- Navidrome agrupa por TAGS, no carpetas.
ND_PID_ALBUM=folderpara álbumes. - Lidarr es el dueño de los ficheros — no dedup manual, arreglar en su config.
/shareen CF Access — sin él, no hay fotos de artista en móvil..lrcdeben sermonxas:monxas— root no los deja leer a Navidrome.- ReplayGain con
rsgain, no el plugin de beets (roto). - AudioMuse GPU:
--security-opt=label=disable(SELinux) +PER_SONG_MODEL_RELOAD=false. - NFS raíz
monxas:monxas—root:rootrompe reproducción (ver NAS reboot). - NO desactivar
ND_ENABLESHARING. media_player.joinde HA puede fallar en silencio para grupos cruzados Sonos+AirPlay — usarplayers/create_group_playernativo de MA (persistente).- El reproductor de la tablet puede tapar F1/JustEat si queda abierto —
musicno está en el orquestadorrefreshAll()(pendiente de arreglo). - mDNS duplicado en 208 — Music Assistant puede aparecer descubierta 2×
en HA (~25 redes Docker anunciando por
network_mode: host); es el mismo servidor, configurar solo la de192.168.0.208:8095.
Reversibilidad / backups¶
- Cuarentenas de dedup (reversibles, con
MANIFEST.txt):/mnt/nfs_media/_music_quarantine_20260724/{dedup2,dedup3}/. - Backups DB Navidrome:
navidrome.db.bak-*. La DB guarda playlists/favoritos/plays. - beets:
library.db.bak-*,config.yaml.bak-*. - AudioMuse: embeddings en Postgres (volumen
am-pgdata) → análisis resumible. - Music Assistant: grupos sincronizados persistentes viven en
config/players(providersync_group) — sobreviven a reinicios del contenedor. - Tablet:
index.html/css/jscon backups.bak-YYYYMMDD[-sufijo]en el propio directorio antes de cada cambio.
Operaciones comunes¶
# Re-escanear tras cambios de tags/ficheros
docker exec navidrome navidrome scan --full
# Re-tag de artistas contra MusicBrainz (arregla tildes/duplicados)
docker exec -d beets sh -c "beet import -q /music >> /tmp/reimport.log 2>&1"
# Estado análisis AudioMuse
sshpass -p alfagann ssh [email protected] \
'curl -s localhost:8123/api/last_task | python3 -m json.tool'
# Medir duplicados de artista (script artdupes.py: normaliza NFKD, quita tildes)
docker cp navidrome:/data/navidrome.db /tmp/nav.db && python3 artdupes.py
# Lanzar clustering AudioMuse (genera playlists automáticas por género/mood/tempo)
curl -X POST http://192.168.0.141:8123/api/clustering/start \
-H "Content-Type: application/json" -d '{"clustering_method":"kmeans","auto_parameter_discovery":true,...}'
# ver /api/config para los valores por defecto reales antes de lanzar
# Crear un grupo persistente en Music Assistant (Sonos+AirPlay sincronizados de verdad)
# ver docs de la API en projects/vera/music-dj.md — dj_group_players hace esto por Vera
Para el DJ conversacional de Vera (pedir música por vibra/mood por Telegram y que suene de verdad en un altavoz, con grupos sincronizados) ver Vera → DJ conversacional.