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)
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:latesttwice —SERVICE_TYPE=flask(app) andSERVICE_TYPE=worker..env(chmod 600):TZ,POSTGRES_USER,POSTGRES_PASSWORD(generate).- Publish flask on
127.0.0.1:$PORT:8000and$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, URLhttp://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
- Organize flat dumps:
$SRC → $MUSIC/<track artist>/<album>/NN - Title.<ext>(tag-driven; case-insensitive folder merge; sanitize "/" and control chars). - Audio-only
.mp4files MUST be renamed.m4a— Navidrome ignores.mp4. - 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. - 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, keyListenBrainzSessionKey. - 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(CLInavidrome 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; headerX-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 → AudioMuseGET /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 →
GEThttps://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=<m>&...&algorithm=session_based_days180_session300_contribution5_threshold15_limit50_skip30→ match bymbz_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/createdforwithAuthorization: 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_IPport 8091:GET /search?q=&limit=→ proxy CLAP search (optional min similarity filter)GET /mix?songId=&count=→ NavidromegetSimilarSongs2(alternatively call Navidrome directly) - Auth: client passes its Subsonic
u,t,s(oru,p); bridge validates via Navidrome/rest/pingand 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
:4533routing/mood/*to 8091 and everything else tonavidrome: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), autht=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 to127.0.0.1and$TS_IP.getOpenSubsonicExtensionslists sonicSimilarity and playbackReport.getSimilarSongs2returns ≥20 tracks;getSonicSimilarTracksreturns 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
.mp4audio is skipped by Navidrome → rename.m4a.- Worker needs the
navidrome_defaultnetwork (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; PUTownerIdafter 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.