scrobble_queue table: (id, user_id, play_event_id, status ∈ {pending, failed}, attempts, next_attempt_at, last_error, enqueued_at) with UNIQUE(play_event_id) for idempotency and a partial index WHERE status='pending' for the worker's hot path
Lifecycle: successful submit DELETEs the queue row + stamps play_events.scrobbled_at (canonical record). Only pending and failed states ever exist in the queue.
Backend pipeline (internal/scrobble/)
listenbrainz/client.go — pure HTTP client for /1/submit-listens. Typed errors ErrAuth (401), ErrPermanent (other 4xx), ErrTransient (5xx + network), *RetryAfterError (429 with parsed Retry-After header). 9 httptest-driven tests including payload shape, auth header, and listen_type=single|import switching.
threshold.go — Qualifies(durationPlayedMs, completionRatio) returning true when ≥240 seconds played OR ≥50% of track length. 6 boundary tests.
queue.go::MaybeEnqueue — gates on user config + threshold + idempotent INSERT (ON CONFLICT DO NOTHING). 5 integration tests.
worker.go — Worker struct with Run (30s ticker), tickOnce (drains up to 50 rows, groups per-user, batches POST), and processBatch (handles 6 outcome paths — success, 429-retry-after, 401-disable-user, ErrPermanent, ErrTransient-with-backoff, ErrTransient-give-up). Backoff schedule 1m → 5m → 30m → 2h → 6h, then permanent fail. 6 integration tests.
Hooks (internal/playevents/writer.go)
RecordPlayEnded and RecordSyntheticCompletedPlay (Subsonic submission=true) both gain a post-commit scrobble.MaybeEnqueue call. Best-effort: errors logged + swallowed (matches the M3 CaptureContextualLikeIfPlaying pattern). 2 new integration tests.
API (internal/api/listenbrainz.go)
GET /api/me/listenbrainz → {enabled, token_set, last_scrobbled_at}. Token write-only — token_set is the only signal back about whether one exists. last_scrobbled_at queried via MAX(play_events.scrobbled_at).
PUT /api/me/listenbrainz body {token?, enabled?}. Clearing token ({token: ""}) auto-forces enabled=false via SQL CASE. Enabling without a token returns 400.
4 integration tests.
Frontend (web/src/routes/settings/+page.svelte)
New minimal /settings page with a single ListenBrainz section. Token field is a password input with Save/Clear buttons (masked when set). Enabled checkbox disabled when no token. "Last scrobbled" timestamp renders when present. Disclaimer about plaintext storage.
web/src/lib/components/Shell.svelte gains Settings in the nav. Helper module web/src/lib/api/listenbrainz.ts exports query/mutation factory functions matching the createSearchQuery/createAlbumQuery pattern. api.put<T> added to client.ts. 5 vitest tests.
go test -short -race ./... — all 15 packages pass (now including internal/scrobble + internal/scrobble/listenbrainz)
Full integration suite — all packages green; pre-existing TestScanner_Integration flake unchanged (CI runs -short which skips it)
golangci-lint run ./... — clean
internal/scrobble coverage: 68.0% (target was ≥80%; 18 integration tests cover all major paths — gap is in less-critical logging branches in processBatch)
internal/scrobble/listenbrainz coverage: 70.2%
cd web && npm run check — 0 errors / 0 warnings (319 files)
cd web && npm test — 180 tests across 30 files (was 175)
cd web && npm run build — succeeds
docker build -t minstrel:m4a-smoke . — succeeds
Worker boot smoke test (5s go run) — server starts, no panic
Manual: generate token → /settings → paste + enable → play track → check listenbrainz.org/ for the listen within ~30s
Decisions ledger
#
Decision
Rationale
1
Per-user token
Forward-compat to multi-user; cost is 2 nullable columns
2
Plaintext storage
Industry norm for self-hosted scrobble apps; key-mgmt is a separate scope
3
Threshold ≥240s OR ≥50% (separate from skip)
Matches LB recommendation; skip threshold serves a different purpose
4
Pull-based 30s timer
Survives restarts/outages; failure handling natural; latency invisible at single-user scale
5
Minimal /settings in M4a
User needs somewhere to put the token; scaffold for M6 to extend
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
First M4 sub-plan (Fable #345). Sends qualifying user plays to ListenBrainz with batching + exponential-backoff retry. Per-user token, plaintext storage, pull-based 30s timer worker, patient backoff (1m → 5m → 30m → 2h → 6h, 5 attempts).
What this ships
Schema (migration
0008_scrobble)users.listenbrainz_token TEXT NULL+users.listenbrainz_enabled BOOLEAN DEFAULT FALSE— per-user config columnsscrobble_queuetable:(id, user_id, play_event_id, status ∈ {pending, failed}, attempts, next_attempt_at, last_error, enqueued_at)withUNIQUE(play_event_id)for idempotency and a partial indexWHERE status='pending'for the worker's hot pathplay_events.scrobbled_at(canonical record). Onlypendingandfailedstates ever exist in the queue.Backend pipeline (
internal/scrobble/)listenbrainz/client.go— pure HTTP client for/1/submit-listens. Typed errorsErrAuth(401),ErrPermanent(other 4xx),ErrTransient(5xx + network),*RetryAfterError(429 with parsedRetry-Afterheader). 9 httptest-driven tests including payload shape, auth header, andlisten_type=single|importswitching.threshold.go—Qualifies(durationPlayedMs, completionRatio)returning true when ≥240 seconds played OR ≥50% of track length. 6 boundary tests.queue.go::MaybeEnqueue— gates on user config + threshold + idempotent INSERT (ON CONFLICT DO NOTHING). 5 integration tests.worker.go—Workerstruct withRun(30s ticker),tickOnce(drains up to 50 rows, groups per-user, batches POST), andprocessBatch(handles 6 outcome paths — success, 429-retry-after, 401-disable-user, ErrPermanent, ErrTransient-with-backoff, ErrTransient-give-up). Backoff schedule1m → 5m → 30m → 2h → 6h, then permanent fail. 6 integration tests.Hooks (
internal/playevents/writer.go)RecordPlayEndedandRecordSyntheticCompletedPlay(Subsonic submission=true) both gain a post-commitscrobble.MaybeEnqueuecall. Best-effort: errors logged + swallowed (matches the M3CaptureContextualLikeIfPlayingpattern). 2 new integration tests.API (
internal/api/listenbrainz.go)GET /api/me/listenbrainz→{enabled, token_set, last_scrobbled_at}. Token write-only —token_setis the only signal back about whether one exists.last_scrobbled_atqueried viaMAX(play_events.scrobbled_at).PUT /api/me/listenbrainzbody{token?, enabled?}. Clearing token ({token: ""}) auto-forcesenabled=falsevia SQL CASE. Enabling without a token returns 400.Frontend (
web/src/routes/settings/+page.svelte)New minimal
/settingspage with a single ListenBrainz section. Token field is a password input with Save/Clear buttons (masked when set). Enabled checkbox disabled when no token. "Last scrobbled" timestamp renders when present. Disclaimer about plaintext storage.web/src/lib/components/Shell.sveltegainsSettingsin the nav. Helper moduleweb/src/lib/api/listenbrainz.tsexports query/mutation factory functions matching thecreateSearchQuery/createAlbumQuerypattern.api.put<T>added toclient.ts. 5 vitest tests.Boot wiring (
cmd/minstrel/main.go)Test plan
go test -short -race ./...— all 15 packages pass (now includinginternal/scrobble+internal/scrobble/listenbrainz)TestScanner_Integrationflake unchanged (CI runs-shortwhich skips it)golangci-lint run ./...— cleaninternal/scrobblecoverage: 68.0% (target was ≥80%; 18 integration tests cover all major paths — gap is in less-critical logging branches inprocessBatch)internal/scrobble/listenbrainzcoverage: 70.2%cd web && npm run check— 0 errors / 0 warnings (319 files)cd web && npm test— 180 tests across 30 files (was 175)cd web && npm run build— succeedsdocker build -t minstrel:m4a-smoke .— succeedsgo run) — server starts, no panicDecisions ledger
/settingsin M4aplay_events.scrobbled_at; queue is a work listOut of scope (deferred)
now-playingendpoint (needs push architecture)Sub-plan progression (M4)
track_similaritytable + weekly cache).🤖 Generated with Claude Code