bvandeusen 78aa9befb6
android / Build + lint + test (push) Successful in 4m12s
fix(connectivity): probe on foreground; a burst can't corroborate ServerDown — #1209
Two changes so a network handoff stops making the app refuse to play music.

## Correction first: half of what I proposed already existed

I recommended "require corroboration before ServerDown, since Unstable is
non-gating." ReachabilityMachine has done exactly that since it was written —
onProbeFailure takes Reachable → Unstable, and escalates only on corroboration
or the 120s backstop. There is even a test named `single probe failure is
unstable not down`. I proposed building a thing that shipped months ago.

Reading the machine properly turned up the real gap, which is narrower and more
specific.

## 1. Probe when the app returns to the foreground

The genuine missing piece, and #1209's own note had it backwards: it listed
this as "already happens via link probe." It doesn't. `recheck()` had exactly
two callers — a button in VersionTooOldBanner and pull-to-refresh — and nothing
observed ProcessLifecycleOwner. The link probe fires on a connectivity
*change*, so an app backgrounded on stable Wi-Fi gets none.

That made a stale ServerDown outlive its cause: the poll loop's delay() is
throttled while screen-off/doze, so recovery waited for whenever the OS next
let the loop run. June's capture recovering at "EXACTLY 22:31:10 app_foreground"
was the throttled delay resuming, not a deliberate probe — same timestamp,
different mechanism, and that difference is the whole bug.

NetworkStatusController now implements DefaultLifecycleObserver and calls the
existing recheck() on ON_START. force = true, so it also bypasses
ARBITRATE_MIN_GAP_MS: a user opening the app is exactly when a stale banner and
a refused track are most visible, and it's once per foreground.

## 2. A burst of op failures no longer corroborates itself

The actual defect in the escalation path. Corroboration required 2 op failures
within 30s — but a link handoff fails every in-flight request at once, so a
burst is ONE event producing N failures, not N independent observations that
the server is gone. Two simultaneous failures walked straight to Unreachable.

onOpFailure now drops a failure landing within CORROBORATION_MIN_SPACING_MS
(3s) of the last recorded one. Above the sub-second window a handoff occupies,
low enough that a real outage still corroborates within seconds once anything
retries.

## Why this matters more than the task implied

#1209 called the follow-ups "cosmetic in the diagnostics". They aren't.
OfflineGatedDataSource.gateOnHealth() throws OfflineException on ServerDown
BEFORE touching the network, and TrackRow disables rows. So a spurious
ServerDown means the app declines to play uncached tracks that would play
fine — for a blip that already resolved. The note's "captured skips advanced
fine" was timing luck, not evidence the gate is harmless.

## Tests

`two op failures plus a failed probe escalate immediately` used timestamps
500ms apart, which the new rule treats as a burst — so I re-spaced it and
renamed it `two SPACED op failures...`. That's a deliberate reversal of an
encoded expectation, not a broken test being patched.

Also re-spaced `stale op failures do not corroborate` (used 0 and 1_000): left
alone it would still have passed, but for the wrong reason — burst-dropping
rather than staleness — and a test that can't fail for its stated reason is
worse than no test.

Added: a burst of four failures plus a failed probe stays Unstable, and a burst
that never recovers still escalates via the sustained backstop, so dropping
duplicates can't make a real outage undetectable.

The foreground hook itself is unverifiable in a JVM test (ProcessLifecycleOwner
needs the framework, and there's no instrumentation lane). Checked instead that
nothing constructs NetworkStatusController outside Hilt, so init's
ProcessLifecycleOwner.get() only runs on the main thread during
Application.onCreate — the same pattern LiveEventsDispatcher already uses.
2026-08-05 14:41:48 -04:00
2026-04-18 17:33:35 +00:00

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.

Minstrel home — your library at a glance

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 — 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

# 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).

Creating the first (admin) account on a fresh instance

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.

The Admin page, where the library scan runs and reports progress

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.

ListenBrainz and Lidarr integration cards in admin Settings

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.

The "Install the Android app" download card in Settings

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.

Generating an invite token in admin Users

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 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 for the authoritative surface.

Updating

Image tags (git.fabledsword.com/bvandeusen/minstrel:<tag>):

  • :latest — the newest blessed image. Moves on every main push 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 :latest at push time; choose it if you want to track main explicitly 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/:

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: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 into dev).
  • main is protected — changes land via PR from dev.
  • Releases are cut by tagging v* off main; 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.

S
Description
Self-hosted music server with OpenSubsonic compatibility, server-side smart shuffle, dual-like model, session-aware radio, and Lidarr integration. Go + Postgres.
Readme MIT
62 MiB
Languages
Go 40.4%
Kotlin 25%
Dart 12.8%
TypeScript 11.9%
Svelte 9.6%
Other 0.1%