The "Updating your mixes…" veil wiped on and straight back off before the update finished, and a number of churn paths never raised it at all. Three reasons it lowered early. refreshBehindVeil held it for refresh().join() + a flat 500ms, but finishing the network pull is nowhere near the end of the visible work: refreshIndex writes only the section id lists, then each tile hydrates through MetadataProvider (null → skeleton → album), and only then does the cover art load. Second, updatingInternal was a plain Boolean cleared in a finally — reconnect and playlist.system_rebuilt routinely arrive together, so whichever pull finished first wiped the veil off while the other was still running. Third, refresh() swallowed every failure in runCatching, so join() returned "fine" after a failed pull: veil off, content unchanged, no retry. So the veil's lifetime is now driven by watching the screen instead of by a guess. UpdateVeilController raises, runs the work (retrying behind the veil), then holds until the content signature has been unchanged for a quiet window AND nothing is still loading — floored by a minimum hold so it cannot flash, capped by a hard ceiling so it cannot strand, and with overlapping triggers folded into one session rather than racing it. Giving up is silent and sets no latch: the reconnect-driven recovery and the freshness sweeper keep retrying afterwards exactly as before. Cover art was the most visible pop-in and the refresh coroutine cannot see it, so the composition reports it upward: ServerImage — the single choke point behind CoverTile for every album/artist/playlist cover — counts its in-flight loads into an ArtSettleTracker the veil waits on. Art also crossfades now (set once on the ImageLoader, so it applies app-wide) with the placeholder fading out over the same window, which softens the pop everywhere the veil isn't involved. Underneath all of it, the churn is largely no longer generated. replaceSection was delete-then-insert per section, un-transacted, so observeBySection emitted emptyList() — a visible collapse — before refilling, seven times in sequence. It is now one @Transaction across all sections (Room notifies once, on commit, so the empty gap is never observed), and the index flow dedups on the id list, so a section whose contents did not move no longer tears down and rebuilds every tile's hydration flow. fetchedAt is restamped on every write, which is why the dedup compares ids rather than rows. Same fix CachedQuarantineDao already carried for the same reason. Trigger set widened per the operator's call: the initial load over a warm cache (a full re-pull that churned every section completely unveiled), manual pull-to-refresh, scan.run_finished (Home never reacted to it at all), and the playlist.created/updated/deleted/tracks_changed kinds. The veil waits for content to be on screen before raising, so a genuinely cold load still gets its skeleton rather than an opaque panel over nothing. refreshError is now cleared on success rather than at the start of each attempt — with retries, clearing it up front made a failing cold start flash the "Welcome to Minstrel" empty state between attempts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Minstrel
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and Lidarr automation — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
State and intelligence belong on the server, not the client.
Highlights
- OpenSubsonic-compatible. Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
- Server-side smart shuffle. Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
- ListenBrainz radio. Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
- Lidarr integration. Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
- Built-in web SPA. Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
- Native Android client, shipped with the server. The signed APK is bundled into every image and attached to each release — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
Quickstart
# compose.yaml
services:
minstrel:
image: git.fabledsword.com/bvandeusen/minstrel:latest
ports: ['4533:4533']
volumes:
# Your music library. Point ./music at wherever your audio files
# live. Mounted read-only — Minstrel never writes to your library.
- ./music:/music:ro
# Generated data: playlist cover collages, artist art, caches.
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
# sets to /app/data — keep this mount on /app/data or your cache
# won't survive a container recreate.
- minstrel-data:/app/data
environment:
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
# Colon-separated library roots to scan; must match the container
# path of the read-only music mount above (/music here).
MINSTREL_LIBRARY_SCAN_PATHS: /music
depends_on: [db]
db:
image: postgres:17
environment:
POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel
# Postgres data dir — users, likes, play history, sessions, settings.
# The one volume you must never lose; back it up with pg_dump.
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
minstrel-data:
pgdata:
docker compose up -d
First run
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of localhost if you're reaching the server over a LAN/VPN address (plain http:// is fine — no TLS required).
1. Create your admin account. Visit http://localhost:4533/register. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
2. Let the first library scan finish. scan_on_startup is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the Admin page (/admin); the scan runs in stages and is incremental, so later restarts only pick up what changed.
3. (Optional) Name the instance and wire up integrations. In admin Settings → Integrations (/admin/integrations), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via MINSTREL_BRANDING_APP_NAME.
4. Install the Android app. Open Settings (/settings) and use the Install the Android app card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
5. Invite the rest of the household. From admin Users (/admin/users), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
For the full configuration surface, see config.example.yaml.
Configuration
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
MINSTREL_BRANDING_APP_NAME— rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.MINSTREL_STORAGE_DATA_DIR— where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to/app/data, which is why the quickstart mounts theminstrel-datavolume there.MINSTREL_LIBRARY_SCAN_PATHS— colon-separated list of music library roots to scan. Supports multiple roots (/music:/podcasts).
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
Most operational keys have a MINSTREL_<SECTION>_<FIELD> env override. Recommendation and events tuning are yaml-only. See config.example.yaml for the authoritative surface.
Updating
Image tags (git.fabledsword.com/bvandeusen/minstrel:<tag>):
:latest— the newest blessed image. Moves on everymainpush and every release. Recommended for most operators.:vYYYY.MM.DD— immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.):main— the rolling post-merge tip. Same image as:latestat push time; choose it if you want to trackmainexplicitly rather than the release line.
Every :latest and every :vYYYY.MM.DD bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
Specs
Authoritative scope lives under docs/:
- Server spec — current implementation focus.
- Client spec — Flutter companion app.
Development
Two concurrent dev processes:
- Backend:
docker compose up— Postgres + Minstrel on:4533. - Frontend:
cd web && npm install && npm run dev— Vite dev server on:5173with HMR. The Vite server proxies/api/*and/rest/*to:4533so session cookies work.
Testing
- Unit + race (no DB):
make test-short. - Full suite incl. integration tests:
make test-integration. This runs against a dedicatedminstrel_testdatabase so a test run never truncates your devminstreldata (admin user, library, likes). It brings up the compose Postgres and creates the test DB if missing. - CI runs both: a fast
go test -short -racegate plus an integration job with its own ephemeral Postgres (.gitea/workflows/test-go.yml).
Production build
docker build -t minstrel . runs the SvelteKit build inside a node stage, copies the output into the golang stage, and //go:embeds it into the final binary. The container serves the SPA from / alongside the API surfaces; no separate static-file server is required.
Branches
- Day-to-day work happens on
dev(or feature branches merged intodev). mainis protected — changes land via PR fromdev.- Releases are cut by tagging
v*offmain; the release workflow builds and pushes the container image to the Gitea registry.
Task and milestone tracking: Fable (Minstrel project, id 12).
License
See LICENSE.





