New `internal/gc` package with a single Worker that runs all five lifecycle / retention sweeps from the 2026-06-02 drift audit on a 1-hour tick. Each sweep is small, idempotent (re-running on already-clean rows is a no-op), and logs its affected-row count. Sweeps (Scribe parent #552): - **#566** GcCloseStalePlayEvents — play_events rows opened > 24h ago that never got a play_ended (client crash, network drop). Synthesizes ended_at from duration_played_ms when known, falls back to now() so the row stops looking "open" to downstream filters (ended_at IS NULL). - **#565** GcClosePlaySessionsWithNoRecentEvents — play_sessions with last_event_at older than 6h get ended_at = last_event_at ("user moved on"); empty sessions older than 1h get closed too (stale handshakes from clients that never recorded a play). The audit caught that the column was added but never populated by any writer — every session row was "open" forever, breaking downstream dedup queries that assume closed semantics. - **#567** GcExpireScrobbleQueueFailedRows — drops scrobble_queue rows in status='failed' older than 14 days. The worker stops retrying after maxAttempts so these otherwise accumulate forever on a persistent ListenBrainz outage / revoked token. - **#574** GcResetStuckSystemPlaylistRuns — flips system_playlist_runs.in_flight back to false on rows whose last_run_at is older than 10 minutes. Catches goroutine-panic wedges where the generator died between SET in_flight=true and SET in_flight=false; the duplicate-prevention check refuses to start a fresh regen while in_flight, so a stuck row would otherwise deadlock all future regens for that user. Records "stuck-row auto-reset by gc" in last_error so the operator can tell auto-reset from a recent real failure. - **#575** GcDeleteExpiredPasswordResets — deletes expired password_resets rows. Unused expired rows go after a 1h grace (gives the operator time to debug an active reset attempt); used rows are kept 7 days for audit. Wiring: - main.go `go gcWorker.Run(ctx)` alongside the other periodic workers (scrobble, similarity, lidarr). - tickOnce fires once at start so a freshly-deployed server does its initial sweep without waiting a full tick, matching the scrobble worker pattern. - Errors per sweep are logged but do NOT abort the remaining ones — a transient pgx error from one query shouldn't prevent the others from running. Tests: - 4 integration tests, one per UPDATE/DELETE sweep, that seed rows-to-sweep + rows-to-leave-alone and assert the right rows changed state. Skip unless MINSTREL_TEST_DATABASE_URL is set (mirrors the api package pattern). - Empty-tables no-op smoke test. - Run() cancellation honoured (no spinning goroutine at test-runner exit). That's all five remaining server-side lifecycle findings from the audit. The Android LOCAL_USER_ID hardcode (#576) is a separate refactor that needs auth-store wiring and stays in the queue.
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.
- Flutter mobile client in flight. Tracking issue #356.
Quickstart
# compose.yaml
services:
minstrel:
image: git.fabledsword.com/bvandeusen/minstrel:latest
ports: ['4533:4533']
volumes:
- ./music:/music:ro
- minstrel-data:/data
environment:
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
MINSTREL_LIBRARY_SCAN_PATHS: /music
depends_on: [db]
db:
image: postgres:17
environment:
POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
minstrel-data:
pgdata:
docker compose up -d
After the stack is up, visit http://localhost:4533/register and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
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— defaults to./data. Holds playlist cover collages and other generated artefacts.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
:main— rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.:v1.0.x— pinned releases. Recommended default. 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.