First M4 sub-plan (Fable #345). Outbound scrobble worker with batching + exponential-backoff retry. Per-user token, plaintext storage, pull-based 30s timer worker, patient backoff (1m → 5m → 30m → 2h → 6h, 5 attempts). New scrobble_queue table; sent rows deleted (canonical record stays in play_events.scrobbled_at). Minimal /settings page in M4a as scaffold for M6's full Settings sub-plan to extend. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
17 KiB
M4a — ListenBrainz outbound scrobble worker
Status: Spec draft, 2026-04-28 Tracking: Fable #345 Milestone: M4 — ListenBrainz scrobble + similarity + radio
1. Goal
Send the user's qualifying plays to ListenBrainz with batching and exponential
retry. Reads from the existing M2 play_events stream; backed by a new
scrobble_queue table that the worker drains every 30 seconds. Per-user token
configuration via a new minimal /settings page in the SPA.
When this slice ships, a play that meets ListenBrainz's "≥240s OR ≥50% completion" threshold appears in the user's LB profile within ~30 seconds, and the system survives LB outages, bad networks, and process restarts without losing or duplicating scrobbles.
2. Non-goals (explicit)
now-playingendpoint — real-time "user is currently listening" status. Requires push architecture; defer until a UI surface needs it.- Listen import — backfilling historical plays into LB. Useful for migration but not part of the core flow.
- MusicBrainz ID resolution at scan time — payload uses MBIDs only when
already populated in
tracks.mbid/albums.mbid/artists.mbid. Better scanner-side MBID coverage is a future improvement. - Encrypted-at-rest token storage — chose plaintext for v1 (industry norm for self-hosted scrobble apps).
- Scrobble status indicator in PlayerBar — latency-sensitive UX; bundle with the future now-playing push.
- Multi-instance queue safety — single worker assumes single Minstrel process. ROW LOCK FOR UPDATE SKIP LOCKED is a future change if/when Minstrel goes multi-instance.
- CLI admin token rotation — operators can
UPDATE users …for break-glass; CLI tooling lands with M6 packaging. - Proactive rate limiting — we honor LB's
Retry-Afterheaders but don't throttle ourselves. Human-scale play rate is well below LB's limits. - Multiple scrobble services (Last.fm, Maloja, etc.) — LB-only by design.
The
internal/scrobble/listenbrainz/package structure leaves room for siblings later if demand emerges.
3. Architecture overview
┌──────────────────────────┐
play_event closed ──► │ scrobble.MaybeEnqueue │ (called from
(M2 RecordPlayEnded) │ - apply LB threshold │ Writer hooks)
│ - INSERT scrobble_queue │
└────────────┬─────────────┘
│
▼
┌────────────────────────┐
│ scrobble_queue table │ status: pending | failed
│ (work list, not log) │
└────────────┬───────────┘
│
▼
tick 30s ─────► ┌────────────────────────────────────┐
(goroutine) │ scrobble.Worker │
│ - SELECT pending, next_attempt<= now │
│ - batch POST to LB submit-listens │
│ - on 2xx: DELETE row + │
│ UPDATE play_events.scrobbled_at │
│ - on 5xx/network: increment │
│ attempts, schedule next │
│ - on 4xx: mark failed, log │
│ - on 429 Retry-After: respect │
│ header │
└────────────────────────────────────┘
│
▼
ListenBrainz https://api.listenbrainz.org
3.1 New Go packages
internal/scrobble/listenbrainz/— pure HTTP client. One method:SubmitListens(ctx, token, listens). No DB, no worker plumbing. Tested againsthttptest.Server.internal/scrobble/threshold.go—Qualifies(durationPlayedMs, completionRatio) bool. Pure function.internal/scrobble/queue.go—MaybeEnqueue(ctx, q, playEventID). Reads user config + threshold, inserts row.internal/scrobble/worker.go— the goroutine. Started fromcmd/minstrel/main.goalongsideplayevents.Writer.
3.2 Hooked into existing code
playevents.Writer.RecordPlayEnded— after the close transaction commits, callscrobble.MaybeEnqueue(ctx, q, playEvent.ID)as a best-effort post-commit step. Errors are logged and swallowed (matches the M3CaptureContextualLikeIfPlayingpattern: scrobble delivery is enrichment, not a precondition for the canonical play history).playevents.Writer.RecordSyntheticCompletedPlay— same hook (Subsonicsubmission=trueplays from/rest/scrobble).internal/api/me.go— two new handlers:handleGetListenBrainzandhandlePutListenBrainz. Wired inMount(...)under/api/me/listenbrainz.cmd/minstrel/main.go— start the worker:worker := scrobble.NewWorker(pool, listenbrainz.NewClient(), logger) go worker.Run(ctx)
4. Database schema
New migration 0008_scrobble.up.sql:
ALTER TABLE users
ADD COLUMN listenbrainz_token TEXT NULL,
ADD COLUMN listenbrainz_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE scrobble_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
play_event_id uuid NOT NULL REFERENCES play_events(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('pending', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error TEXT NULL,
enqueued_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (play_event_id)
);
CREATE INDEX scrobble_queue_pending_idx
ON scrobble_queue (next_attempt_at)
WHERE status = 'pending';
Down migration drops the table and the user columns.
Reused (no schema changes):
play_events.scrobbled_at— already exists from M2 migration 0005, currently always NULL. Worker stamps it on successful submit.play_events.duration_played_ms,completion_ratio— already populated by M2; used for the LB eligibility threshold.
Lifecycle:
- Successful submit →
DELETE FROM scrobble_queue WHERE id = $1ANDUPDATE play_events SET scrobbled_at = now() WHERE id = $1. Canonical record stays inplay_events; queue is purely a work list. UNIQUE(play_event_id)makesMaybeEnqueueidempotent: re-running the close path on a play_event with an existing queue row is a no-op viaON CONFLICT DO NOTHING.- No
'sent'status enum value — the simpler binarypending/failedstate machine is sufficient because successful rows are deleted.
5. Backend components
5.1 ListenBrainz client (internal/scrobble/listenbrainz/client.go)
type Listen struct {
ListenedAt int64
Track Track
}
type Track struct {
ArtistName string
TrackName string
ReleaseName string
DurationMs int
RecordingMBID string
ArtistMBIDs []string
ReleaseMBID string
}
type Client struct {
BaseURL string // default https://api.listenbrainz.org
HTTP *http.Client
}
// Errors are typed so the worker can branch on response semantics.
var (
ErrAuth = errors.New("listenbrainz: auth rejected") // 401
ErrPermanent = errors.New("listenbrainz: permanent error") // other 4xx
ErrTransient = errors.New("listenbrainz: transient error") // 5xx, network
)
type RetryAfterError struct{ Wait time.Duration }
func (e *RetryAfterError) Error() string { ... }
func (c *Client) SubmitListens(ctx context.Context, token string, listens []Listen) error
Sets Authorization: Token <token>. POSTs to /1/submit-listens with
payload_type=import for batches >1, single for batches of 1 (LB convention).
On 429, parses the Retry-After header and returns *RetryAfterError.
5.2 Threshold (internal/scrobble/threshold.go)
// Qualifies returns true if a closed play_event meets ListenBrainz's
// recommended scrobble threshold.
func Qualifies(durationPlayedMs int, completionRatio float64) bool {
return durationPlayedMs >= 240_000 || completionRatio >= 0.5
}
5.3 Enqueue (internal/scrobble/queue.go)
// MaybeEnqueue inserts a scrobble_queue row for the given play_event if:
// 1. The user has listenbrainz_enabled = true with a non-empty token.
// 2. The play_event passes Qualifies().
// Idempotent via UNIQUE(play_event_id) — re-runs are no-ops.
// Returns nil even when the row is skipped (no-op is not an error).
// Best-effort: callers (the playevents.Writer hooks) should log returned
// errors but not propagate them, since scrobble delivery is enrichment
// and must not block the canonical play history.
func MaybeEnqueue(ctx context.Context, q *dbq.Queries, playEventID pgtype.UUID) error
5.4 Worker (internal/scrobble/worker.go)
type Worker struct {
pool *pgxpool.Pool
client *listenbrainz.Client
logger *slog.Logger
tick time.Duration // 30s in production, injectable for tests
now func() time.Time // injectable for tests
batch int // up to 50 rows per tick
}
func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker
// Run blocks until ctx is cancelled.
func (w *Worker) Run(ctx context.Context)
// tickOnce drains up to `batch` pending rows. Exposed for tests.
func (w *Worker) tickOnce(ctx context.Context) error
Backoff schedule (constants in worker.go):
var backoffSchedule = []time.Duration{
1 * time.Minute,
5 * time.Minute,
30 * time.Minute,
2 * time.Hour,
6 * time.Hour,
}
const maxAttempts = 5
// backoffDelay maps a failure count (the value of the `attempts` column
// AFTER the failure has been recorded) to the delay before the next
// attempt. attempts=1 (first failure just happened) → backoffSchedule[0]
// = 1m. attempts=5 → backoffSchedule[4] = 6h. attempts >= 6 → give up.
// Returns (_, false) when attempts > maxAttempts.
func backoffDelay(attempts int) (time.Duration, bool)
Per-row outcome handling:
- 2xx →
DELETE FROM scrobble_queue WHERE id = $1+UPDATE play_events SET scrobbled_at = now() WHERE id = $play_event_id. *RetryAfterError(429) →UPDATE … SET next_attempt_at = now() + WaitWITHOUT incrementingattempts(server told us to wait, not that we failed).ErrTransient(5xx, network) → incrementattempts, look upbackoffDelay(attempts). Iffalse, markfailed. Otherwise schedule next.ErrPermanent(4xx) → markfailedimmediately, no retry.ErrAuth(401) → markfailedimmediately AND setusers.listenbrainz_enabled = FALSE(defensive: bad token shouldn't keep retrying or feed future rows).
5.5 API endpoints (internal/api/me.go)
GET /api/me/listenbrainz
→ 200 { enabled: boolean, token_set: boolean, last_scrobbled_at: string|null }
PUT /api/me/listenbrainz
body: { token?: string, enabled?: boolean }
- token: any string sets the token. Empty string clears it AND forces enabled=false.
- enabled: requires a non-empty stored token. 400 if attempting enabled=true with no token.
→ 200 { enabled, token_set, last_scrobbled_at }
The token is write-only — GET never returns the actual value, only
token_set: bool. last_scrobbled_at is computed via MAX(scrobbled_at) FROM play_events WHERE user_id = $1.
6. Frontend (minimal /settings)
New page web/src/routes/settings/+page.svelte with a single "ListenBrainz"
section. Form:
- Token field: password input with Save button when unset; masked
"••••••••• (set)" + Clear button when set. Save calls
PUT /api/me/listenbrainz { token }. Clear callsPUT { token: "" }. - Enabled checkbox: "Send my plays to ListenBrainz". Disabled when no
token. Toggle calls
PUT { enabled: !current }. - Last scrobbled at: localized timestamp of
last_scrobbled_atfrom the GET response. - Disclaimer: "Tokens are stored unencrypted in this server's database — treat as sensitive."
Shell nav (web/src/lib/components/Shell.svelte) gains { href: '/settings', label: 'Settings' } after /playlists.
Helpers in web/src/lib/api/client.ts — confirm apiPut exists; add it if
not (mirrors the existing apiGet shape).
7. Test plan
7.1 Pure unit tests
threshold_test.go: boundary cases (exactly 240s, exactly 50%, just under both, both true).worker_test.go:backoffDelay(1) == 1m,backoffDelay(2) == 5m,backoffDelay(3) == 30m,backoffDelay(4) == 2h,backoffDelay(5) == 6h,backoffDelay(6)returns_, false.listenbrainz/client_test.go(useshttptest.Server):- 2xx → nil
- 401 →
ErrAuth - 400, 403 →
ErrPermanent - 500, 503 →
ErrTransient - 429 with
Retry-After: 60→*RetryAfterError{Wait: 60s} - Network failure mid-request →
ErrTransient - Body shape (JSON) and
Authorization: Token <token>header asserted
7.2 Integration (live test DB)
-
queue_test.go:- Idempotent:
MaybeEnqueuetwice for same play_event = one row - User with
listenbrainz_enabled=false→ no row - User with empty token → no row
- Play_event below threshold → no row
- Play_event passing threshold → row inserted (status=pending, attempts=0, next_attempt_at ≈ now())
- Idempotent:
-
worker_integration_test.go(mocked LB via httptest):- 1 pending row + 200 → row deleted,
play_events.scrobbled_atpopulated - 503 once → row remains,
attempts=1,next_attempt_at ≈ now() + 1m - 503 five times → row marked
failed,last_errorpopulated - 401 → row marked
failedimmediately,users.listenbrainz_enabledset toFALSE - 429 with
Retry-After: 300→ row remains,next_attempt_at ≈ now() + 5m,attemptsNOT incremented - Batch of 10 pending → single LB POST, 10 listens in payload
- 1 pending row + 200 → row deleted,
-
internal/api/me_test.goextensions:- GET when no token →
{enabled:false, token_set:false, last_scrobbled_at:null} - PUT
{token: "abc"}→ DB updated, GET showstoken_set:true - PUT
{token: ""}→ token cleared,enabledforced to false - PUT
{enabled: true}while no token → 400 last_scrobbled_atpopulated fromMAX(play_events.scrobbled_at)
- GET when no token →
7.3 Frontend (web/src/routes/settings/settings.test.ts)
- Token-not-set state: input + Save button render
- Token-set state: masked + Clear button render
- Save mutation: PUT body has the typed token
- Enabled checkbox: disabled when no token
- Last-scrobbled-at: localized timestamp renders when present
- Mocked
apiGet/apiPut; no real network
7.4 Coverage target
internal/scrobble/... ≥ 80%. M3 combined coverage stays well above the 70%
floor with these additions.
7.5 Manual verification post-merge
- Generate a token at https://listenbrainz.org/profile/
- /settings → paste token → Save → toggle enabled
- Play a track for ≥240s OR finish it
- Within 30s, check listenbrainz.org/ → listen appears
- /settings → "Last scrobbled" timestamp populates
8. Backwards compatibility
- New migration; no changes to existing schema beyond two nullable columns
on
usersand the newscrobble_queuetable. /api/me/listenbrainzis new; no existing endpoints change.MaybeEnqueueis a new hook inplayevents.Writer; if all users havelistenbrainz_enabled=false(the default after migration), the hook is a no-op and behavior is unchanged.- The new
/settingspage is additive; the rest of the SPA is unchanged. playevents.Writer.RecordPlayEndedandRecordSyntheticCompletedPlaysignatures are unchanged. Only their bodies gain the enqueue call.
9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Per-user token (vs global) | Forward-compat to multi-user; cost is 2 nullable columns. |
| 2 | Plaintext token storage (vs encrypted) | Industry norm for self-hosted scrobble apps; key-management is a separate scope. |
| 3 | Threshold ≥240s OR ≥50% (separate from skip threshold) | Matches LB recommendation; skip threshold serves a different purpose. |
| 4 | Pull-based 30s timer (vs push) | Survives restarts/outages; failure handling natural; latency invisible at single-user scale. |
| 5 | Minimal /settings page in M4a (vs API-only) |
User needs somewhere to put the token; scaffold for M6 to extend. |
| 6 | Patient backoff: 1m → 5m → 30m → 2h → 6h, 5 attempts (~9h window) | Survives overnight LB outages without unbounded queue growth. |
| 7 | Delete sent rows; keep failed rows | Canonical record in play_events.scrobbled_at; queue is a work list. |
10. Sub-plan progression (M4)
- M4a (this) — outbound scrobble worker.
- M4b — inbound similarity ingest (
track_similaritytable + LB similarity fetcher; weekly cache). - M4c — radio similarity-driven candidate pool + queue-refresh-at-80% (closes M4).