At boot the scan lists the library, the resolver removes wrong-slot copies and marks their tracks missing, then the scan reconciles from its list and marks them present: 7 of the 13 removed on 2026-10-20 15:00 read as present with no file, and would not be wanted back until a later scan. A pass now waits while a scan younger than StuckScanThreshold is in flight and tries again a minute later; a stuck run does not hold it up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Minstrel
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and a wanted list it fetches for you — 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.
- Finds, downloads and files music itself. A wanted list — requests, followed artists, albums gone missing — searched through indexers you configure and downloaded by a client you run. Each download is checked against MusicBrainz, tagged, and filed into your library, and a better copy replaces a poor one. Optional, and off until you add a search service and a download client.
- 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).
Scope and responsible use
Minstrel is a library server. It indexes files on disk you point it at, and streams them to your own clients.
Minstrel ships no indexers, no trackers, no torrent / Usenet / NZB engine of its own, and no DRM circumvention of any kind.
Acquisition is optional and inert until you configure it. You add the search service (a Prowlarr instance or a Torznab indexer) and the download client (qBittorrent) you already run; Minstrel then searches those for the albums on its wanted list, hands the release it picks to your client, and checks and files what arrives. It configures no sources on your behalf: any it uses are ones you added yourself.
What you put in your library, and what sources you configure, are your responsibility. Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
Minstrel is not affiliated with or endorsed by ListenBrainz, MusicBrainz, Prowlarr, qBittorrent, or Subsonic.
Quickstart
# compose.yaml
services:
minstrel:
image: git.fabledsword.com/bvandeusen/minstrel:latest
# Reachable from your LAN at http://<host>:4533. If this host faces the
# internet, bind it to 127.0.0.1 and put an HTTPS proxy in front instead:
# see docs/hosting.md.
ports: ['4533:4533']
volumes:
# Your music library. Point ./music at wherever your audio files
# live. Writable, because Minstrel files the albums it downloads into
# it (as Artist/Album (Year)/..., tagged with MusicBrainz ids) and
# deletes a file when an admin asks it to (for example, quarantine's
# "Delete file"). Files already there are never moved, renamed or
# retagged. The container runs as uid 1000, so that user needs write
# access to the folders. Mount it :ro to forbid writes: those actions
# then refuse, say why, and change nothing.
- ./music:/music
# 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 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 on a network you trust; a server reachable from the internet belongs behind HTTPS, which docs/hosting.md walks through.
1. Create your admin account. Visit http://localhost:4533/register. The first account on a fresh instance becomes the administrator, and creating it asks for the setup token the server prints in its log (docker compose logs minstrel | grep setup_token), so nobody else can claim a newly exposed server first. 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) Set up acquisition and connections. In admin Acquisition (/admin/acquisition), add a search service and a download client so Minstrel can fetch what's wanted; each person adds their own ListenBrainz token (scrobbling + similarity radio) under Settings → Connections (/settings/connections). 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.
Hosting Minstrel on the internet: see docs/hosting.md. What Minstrel does to protect accounts, and why: docs/security.md.
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 (per-user scrobble + similarity tokens) and acquisition (search services and download clients) are configured in the 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— production. Tracksmain's tip and moves on everymainpush and every release. What most operators should run.:<commit-sha>— the rollback unit. Everymainpush publishes one, so any production commit is addressable without a release ceremony. Immutable: a given SHA tag is never re-pushed. Pin one if you need a deployment that cannot change under you, and use it to roll back.:dev— the rolling test channel, rebuilt on every push todevand carrying its own freshly-built Android APK. Run this to try something before it ships. It moves constantly, has no per-commit tag, and its only recovery path is forward — if a:devimage is broken, the fix is the next push, not a rollback.
That is the whole tag map. There are no version-numbered image tags, and no :main. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does /healthz. Release tags in git are still vYYYY.MM.DD.HHMM; they name a changelog entry and the APK attached to it, not an image.
Rolling back to :<commit-sha> pins the server code at that commit — not the server-and-app pair. The Android APK is baked in at image build time, so a SHA image carries whichever app was current when that commit was built, which may be older than what :latest bundles now. If both halves matter, check what the image bundles rather than trusting the tag's name.
Every :latest, :<commit-sha> and :dev bundles a signed Android APK, so the in-app update channel is always live. All are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a :dev server and the in-app updater offers that channel's build.
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
Releases up to 2026-09-10 also published a :vYYYY.MM.DD[.HHMM] image tag. Those images still exist and still work — they are simply not extended.
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 (theintegrationlane in.gitea/workflows/release.yml, which also gates every image publish).
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 the signed APK, attaches it to the release, and refreshes:latestaround it.
Task and milestone tracking: Fable (Minstrel project, id 12).
License
See LICENSE.





