Skip to content

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 en config.xml.
  • AudioMuse / Music Assistant: monxas/alfagann SSH al Bazzite y al 208 (puerto 22; el wsl-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)

docker exec navidrome navidrome scan --full   # re-lee TODOS los tags

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:

docker exec beets sh -c 'find /music -name "*.lrc" -exec chown 1000:1000 {} + ; \
                         find /music -name "*.lrc" -exec chmod 644 {} +'

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/all muestra solo Dormitorio 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).
  • getOpenSubsonicExtensionstranscodeOffset, 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 /share funcionando — 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=alwaysfull (el hallazgo más peligroso): con always, 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. Con full solo purga en escaneos completos deliberados, nunca en los rápidos del watcher.
  • 1.894 ficheros root:root en 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_ME borradas (restos de los tests del DJ: library_sync_back de 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 -t del 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.sh recorre getArtistInfo2 para 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_CONFIGFILE apunta 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 getCoverArt del 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)
El segundo crea una entidad 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 en js/config.local.js (gitignored).
  • Despliegue: SSH ha-addon + sudo tee (nunca scp a 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 a scrobble() incondicionalmente, sin mirar si autoplay era true. 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" (Subsonic submission=false) solo se manda en el evento play real del <audio>; el scrobble de verdad (submission=true) solo al cruzar un umbral en tickSeek() — 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ón submission=false inmediata, ninguna submission=true prematura.
  • 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 por t=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/ renderStackTop no comprobaban si su resultado seguía vigente tras el await — a diferencia de doSearch, 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: navToken incrementado 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 via call(): usaban fetch() directo, que no detecta que Subsonic responda HTTP 200 con status:"failed" en el JSON — un fallo real (permisos, id inválido) se trataba como éxito. Ahora usan call(), 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 _blobCache en core.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" — pero renderNowPlaying() nunca lo implementaba, dejando el volumen inaccesible ahí. Añadido, reutilizando la lógica de arrastre ya existente (wireVolumeControl(), extraída de attachSeek()). Gotcha real al implementarlo: los IDs elegidos al principio (np-vol-track/np-vol-fill) ya existían en now-playing.js/index.html para el OTRO "ahora suena" (el de vídeo/Jellyfin, no música) — el choque de nombres hacía que getElementById devolviera el elemento equivocado (oculto, tamaño cero). Renombrado a mus-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)

  1. Navidrome agrupa por TAGS, no carpetas. ND_PID_ALBUM=folder para álbumes.
  2. Lidarr es el dueño de los ficheros — no dedup manual, arreglar en su config.
  3. /share en CF Access — sin él, no hay fotos de artista en móvil.
  4. .lrc deben ser monxas:monxas — root no los deja leer a Navidrome.
  5. ReplayGain con rsgain, no el plugin de beets (roto).
  6. AudioMuse GPU: --security-opt=label=disable (SELinux) + PER_SONG_MODEL_RELOAD=false.
  7. NFS raíz monxas:monxasroot:root rompe reproducción (ver NAS reboot).
  8. NO desactivar ND_ENABLESHARING.
  9. media_player.join de HA puede fallar en silencio para grupos cruzados Sonos+AirPlay — usar players/create_group_player nativo de MA (persistente).
  10. El reproductor de la tablet puede tapar F1/JustEat si queda abierto — music no está en el orquestador refreshAll() (pendiente de arreglo).
  11. 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 de 192.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 (provider sync_group) — sobreviven a reinicios del contenedor.
  • Tablet: index.html/css/js con 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.