test-go / test (push) Successful in 1m43s
test-web / test (push) Successful in 1m13s
test-go / integration (push) Successful in 4m12s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 38s
release / Verify release artifacts (tag releases only) (push) Skipped
The image tag map was the inverse of family rules 145 and 147 on every count: it published :vYYYY.MM.DD.HHMM that nobody pinned, published :main that rule 147 says should not exist, and published no commit-addressable image at all — so the rollback unit the rule names did not exist in this repo. A bad main push had nothing to roll back to but the previous release tag, which may be many commits back. The whole map is now: dev → :dev main → :latest + :<sha> tag → :latest A release refreshes the channel and mints nothing else. The tag build rebuilds the SAME SOURCE as main's build minutes earlier, differing only in which APK is baked in, so rule 145's immutability clause applies directly: move the channel tag, never re-push a commit-addressable one. :latest has to move here rather than waiting for the next main push, or the channel would carry the previous release's APK indefinitely — a channel that cannot refresh itself (rule 146). Two consequences that are not optional: The verify job asserted the :<version> image existed. With version tags gone that would fail every release for a tag nothing mints. Re-pointed at the :<sha> image rather than deleted — deleting it is the tempting way to make a failing guard go green, and it earns its keep twice now: it still catches an image push that silently did not happen, and it additionally proves the ordering, since a tag cut on a commit whose main build never completed has no rollback target. The server's self-reported version was the literal string "main" or "dev". That was survivable while :vYYYY.MM.DD.HHMM existed to identify a build; with version tags gone it is the ONLY thing that says which build is running, and two dev images months apart were indistinguishable. It now carries the derived name from ci/version.sh on every lane, with the channel as a sibling field (rule 149) rather than folded into the string. Surfaced at /healthz and beside the version in Settings. Guards added for each arm of the policy, and every one was falsified against the specific regression it names before committing. That caught two real bugs in the guards themselves: stepBody cut at the next `- name:`, which returns an EMPTY body for the last step in a job and made the assertions pass vacuously, and its replacement cut at any blank line followed by indentation, which truncated a step mid-run-block. The helper now refuses an empty body outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
170 lines
12 KiB
Markdown
170 lines
12 KiB
Markdown
# 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.
|
|
|
|
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
|
|
|
|
## 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 — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
|
|
- **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](https://git.fabledsword.com/bvandeusen/minstrel/releases) — 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 serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
|
|
|
|
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
|
|
|
|
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
|
|
|
|
**What you put in your library, and what sources you configure in your own Lidarr, 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 Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
|
|
|
|
## Quickstart
|
|
|
|
```yaml
|
|
# 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:
|
|
```
|
|
|
|
```bash
|
|
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).
|
|
|
|
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
|
|
|
**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.
|
|
|
|
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
|
|
|
**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`.
|
|
|
|
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
|
|
|
|
**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.
|
|
|
|
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The "Install the Android app" download card in Settings"></a>
|
|
|
|
**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.
|
|
|
|
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
|
|
|
|
For the full configuration surface, see [`config.example.yaml`](./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 the `minstrel-data` volume 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`](./config.example.yaml) for the authoritative surface.
|
|
|
|
## Updating
|
|
|
|
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
|
|
|
- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
|
|
- `:<commit-sha>` — the rollback unit. Every `main` push 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 to `dev` and 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 `:dev` image 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/`](./docs):
|
|
|
|
- [Server spec](./docs/smart-music-server-spec.md) — current implementation focus.
|
|
- [Client spec](./docs/smart-music-client-spec.md) — Flutter companion app.
|
|
|
|
## Development
|
|
|
|
Two concurrent dev processes:
|
|
|
|
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
|
|
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work.
|
|
|
|
### Testing
|
|
|
|
- Unit + race (no DB): `make test-short`.
|
|
- Full suite incl. integration tests: `make test-integration`. This runs
|
|
against a dedicated `minstrel_test` database so a test run never
|
|
truncates your dev `minstrel` data (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 -race` gate 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:embed`s 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 into `dev`).
|
|
- `main` is **protected** — changes land via PR from `dev`.
|
|
- Releases are cut by tagging `v*` off `main`; the release workflow builds the signed APK, attaches it to the release, and refreshes `:latest` around it.
|
|
|
|
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
|
|
|
## License
|
|
|
|
See [LICENSE](./LICENSE).
|