feat: M4a outbound ListenBrainz scrobble worker #26
Reference in New Issue
Block a user
Delete Branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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