Snippet

Self-hosted Music Stack

Navidrome + AudioMuse-AI + ListenBrainz + a thin client: a Tailscale-only music stack with no public ports.

1254 words7 min read

Rebuild: self-hosted music stack

Reproduce this architecture exactly. Docker Compose, Linux host, Tailscale-only exposure. Placeholders: $TS_IP (tailnet IPv4), $MUSIC=/home/asuna/Music, $DATA=/home/asuna/navidrome/data, $UID_GID=1000:1000, $TZ=UTC.

0. Topology

client ──HTTP──► Navidrome :4533 (Subsonic/OpenSubsonic)
                    │ plugin audiomuseai
                    ▼
             AudioMuse flask :8000 ── same docker network as Navidrome
             AudioMuse worker ──► navidrome:4533 (Subsonic API)
Navidrome ──outbound──► ListenBrainz (scrobbles)
playlist-builder (systemd user timer) ──► Navidrome native API + AudioMuse + LB labs
mood-mix-api :8091 (optional) ──► AudioMuse CLAP text search

Rules: bind published ports to 127.0.0.1 and $TS_IP only — never 0.0.0.0. No public exposure.

1. Host prep

  • Docker + docker compose v2. Host user uid/gid 1000 owns both the music and data dirs.
  • No cron daemon assumed: schedule user jobs with systemd user timers; loginctl enable-linger $USER.
  • Music: $MUSIC (mounted read-only into Navidrome). Data: $DATA.

2. Navidrome (~/navidrome/docker-compose.yml)

YAML
services:
  navidrome:
    image: deluan/navidrome:latest
    container_name: navidrome
    restart: unless-stopped
    user: "1000:1000"
    ports:
      - "127.0.0.1:4533:4533"
      - "$TS_IP:4533:4533"
    volumes:
      - ./data:/data
      - /home/asuna/Music:/music:ro
    environment:
      ND_MUSICFOLDER: /music
      ND_DATAFOLDER: /data
      ND_LOGLEVEL: info
      ND_SCANSCHEDULE: "1h"
      ND_SESSIONTIMEOUT: 24h
      ND_PLUGINS_ENABLED: "true"
      ND_PLUGINS_AUTORELOAD: "true"
      ND_AGENTS: audiomuseai,lastfm,deezer,listenbrainz   # order matters
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:4533/ping"]
      interval: 1m
      timeout: 5s
      retries: 3
      start_period: 20s

First visit creates the admin user. Keep ND_AGENTS order: Navidrome uses the FIRST agent supporting a capability — audiomuseai must stay first for sonic similarity.

3. AudioMuse-AI (~/audiomuse/)

deployment/docker-compose.yaml from github.com/NeptuneHub/AudioMuse-AI, modified:

  • services: postgres:15-alpine (no host port), ghcr.io/neptunehub/audiomuse-ai:latest twice — SERVICE_TYPE=flask (app) and SERVICE_TYPE=worker.
  • .env (chmod 600): TZ, POSTGRES_USER, POSTGRES_PASSWORD (generate).
  • Publish flask on 127.0.0.1:$PORT:8000 and $TS_IP:$PORT:8000 (here $PORT=8008).
  • Attach BOTH flask and worker to the external network navidrome_default (worker does the library fetching — without this it fails with "Failed to resolve 'navidrome'"). Add at top level: networks: { navidrome_default: { external: true } }
  • Start. Wizard at http://$TS_IP:8008: choose Navidrome, URL http://navidrome:4533, a Navidrome account, then set AudioMuse login + API_TOKEN.
  • Run the first full analysis (incremental afterwards). Do NOT restart Navidrome while analysis runs — album fetches fail and tracks get skipped.
  • Install plugin: download audiomuseai.ndp (NeptuneHub/AudioMuse-AI-NV-plugin releases) into $DATA/plugins/. Restart Navidrome or Rescan plugins.
  • Enable plugin → config: apiUrl=http://audiomuse-ai-flask:8000, apiToken=<API_TOKEN>.
  • Keep Navidrome + core + plugin versions aligned.

4. Library pipeline

  1. Organize flat dumps: $SRC → $MUSIC/<track artist>/<album>/NN - Title.<ext> (tag-driven; case-insensitive folder merge; sanitize "/" and control chars).
  2. Audio-only .mp4 files MUST be renamed .m4a — Navidrome ignores .mp4.
  3. Add MusicBrainz IDs only (6 tags, leave other tags untouched): MUSICBRAINZ_TRACKID (recording), RELEASETRACKID, ALBUMID (release), RELEASEGROUPID, ARTISTID, ALBUMARTISTID. Pipeline: beets (singleton mode) for metadata matching → custom fallback matcher for multi-artist tags / "(feat...)" / "(From ...)", dedupe, dry-run, backup tag manifest, write with mutagen (FLAC Vorbis comments; M4A freeform atoms ----:com.apple.iTunes:MusicBrainz Track Id). Verify audio bytes unchanged.
  4. Rescan: hourly, or restart Navidrome after tag writes.

5. ListenBrainz

  • Per user: Settings → Personal → listenbrainz.org token. Navidrome stores it in the DB table user_props, key ListenBrainzSessionKey.
  • Submissions require a VERIFIED MetaBrainz email (otherwise HTTP 401 on every listen).
  • Navidrome counts plays only on scrobble/reportPlayback, never on stream.
  • MBIDs in tags make LB discovery + scrobble matching work; the LB agent needs artist MBIDs.

6. Playlist generator (daily, per user)

Script reads Navidrome's SQLite read-only and writes via the native API.

  • Bot: create an admin user playlistbot (CLI navidrome user create -a, password prompt needs a TTY — drive it via a pty). Credentials in ~/.config/playlist-builder.env (600).
  • Auth: POST /auth/login {username,password} → token; header X-ND-Authorization: Bearer <t>.
  • Playlist writes: POST /api/playlist {name, comment, public:false} → id; PUT /api/playlist/{id} {ownerId:<user>} (create always assigns the caller as owner); POST /api/playlist/{id}/tracks {ids:mediaFileIds}; DELETE /api/playlist/{id}/tracks?id=<playlistTrackRowId>; DELETE /api/playlist/{id}.
  • Mark generated playlists in comment = "auto-generated by playlist-builder"; only touch those; delete stale marked playlists that are no longer produced. Keep user playlists safe.
  • Listening window: scrobbles table (epoch timestamps), last 14 days; fallbacks 30d → all-time.
  • Song playlists: SONG_COUNT=7 (<Title> Radio), top recent songs → AudioMuse GET /api/similar_tracks?item_id=<id>&n=30; dedupe across the set; 30 tracks.
  • Artist playlists: ARTIST_COUNT=5 (<Artist> Mix): top recent artists after splitting comma/&/feat/with artist tags; exclude names credited as composer that are never the primary artist (filters lyricists); content = that artist's library tracks (recent first)
  • AudioMuse sonic fill-in to 25.
  • Ratio: SONG_COUNT = round(ARTIST_COUNT * 1.4) (40% more song playlists).
  • LB Discovery: user's liked+recent recording MBIDs → GET https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=<m>&...&algorithm=session_based_days180_session300_contribution5_threshold15_limit50_skip30 → match by mbz_recording_id → tracks not recently played.
  • Rediscover: liked/played tracks not played for 14+ days, by play count.
  • LB mirrors: GET https://api.listenbrainz.org/1/user/<user>/playlists/createdfor with Authorization: Token <user token> → mirror playlists whose recordings exist locally.
  • Scale counts down when data is thin; never publish under 5 tracks (discovery: 3).
  • systemd user timer: OnCalendar=--* 00:00:00 Asia/Kolkata, Persistent=true.

7. Mood/type search bridge (optional, not persisted)

  • AudioMuse POST /api/clap/search {query, limit} → {results:{item_id,title,author,similarity}} (Bearer API token; warm ~200 ms after /api/clap/warmup).
  • Tiny HTTP service on 127.0.0.1 + $TS_IP port 8091: GET /search?q=&limit= → proxy CLAP search (optional min similarity filter) GET /mix?songId=&count= → Navidrome getSimilarSongs2 (alternatively call Navidrome directly)
  • Auth: client passes its Subsonic u,t,s (or u,p); bridge validates via Navidrome /rest/ping and caches 5 min. AudioMuse token stays server-side. Rate-limit per user.
  • If the client must talk to ONE endpoint, put a reverse proxy on :4533 routing /mood/* to 8091 and everything else to navidrome:4533 (Navidrome then has no host port).
  • No playlist is ever created for these results — the client queues them transiently.

8. Client contract

  • Base URL http://$TS_IP:4533. Subsonic 1.16.1, JSON (f=json), auth t=md5(password+salt)+s.
  • Scrobbling REQUIRED: reportPlayback (starting/playing/paused/stopped + positionMs, playbackRate) or classic scrobble now-playing + submission after 50%/4 min.
  • Instant Mix (non-persisted): getSimilarSongs2?id=&count=50.
  • Discover extensions via getOpenSubsonicExtensions. Expect: sonicSimilarity, playbackReport, songLyrics, transcoding, transcodeOffset, topSongsByArtistId, indexBasedQueue, formPost.
  • sonicSimilarity: getSonicSimilarTracks (entry+similarity score), findSonicPath (song journey).
  • Artist: getArtistInfo2 (similar artists/bio/externalUrl), getTopSongs?artistId=.
  • Playlists: getPlaylists/getPlaylist; refresh on foreground/pull-to-refresh (daily 00:00 IST and weekly cluster changes). Daily playlists are private per user.
  • Loved/ratings: getStarred2, star/unstar, setRating. Lyrics: getLyricsBySongId (+ getLyrics).
  • Media: stream direct; quality via maxBitRate/format; Range + offset; download for offline; getCoverArt?size=. Support FLAC and M4A. IDs are opaque strings.

9. Verification

  • ss -tln: 4533 + 8008 (+8091) bound ONLY to 127.0.0.1 and $TS_IP.
  • getOpenSubsonicExtensions lists sonicSimilarity and playbackReport.
  • getSimilarSongs2 returns ≥20 tracks; getSonicSimilarTracks returns scores; findSonicPath ≥2.
  • Play a track past 50%: now-playing + permanent listen appear on listenbrainz.org.
  • Playlists exist per user; timer active (systemctl --user list-timers).
  • Run a probe: 20+ checks pass (ping, extensions, similar, sonic, playlists, lyrics, reportPlayback, stream direct+transcode, cover art, starred).

10. Secrets (never commit; chmod 600)

  • ~/.config/playlist-builder.env: NAVIDROME_URL, bot user/password, DB path, AUDIOMUSE_URL, AUDIOMUSE_TOKEN.
  • ~/audiomuse/.env: TZ, POSTGRES_USER, POSTGRES_PASSWORD.

Gotchas

  • .mp4 audio is skipped by Navidrome → rename .m4a.
  • Worker needs the navidrome_default network (flask alone is not enough).
  • Don't restart Navidrome during an AudioMuse analysis.
  • LB returns 401 until the MetaBrainz email is verified.
  • stream does not count as a play — only scrobble/reportPlayback.
  • Playlist create ignores ownerId; PUT ownerId after creation.
  • Delete playlist tracks by playlist-track row id (?id=), not media file id.
  • AudioMuse clustering ("mood/vibe" playlists) is intentionally DISABLED in this build.