diff --git a/docs/superpowers/plans/2026-04-28-m4a-scrobble.md b/docs/superpowers/plans/2026-04-28-m4a-scrobble.md new file mode 100644 index 00000000..15d6d92a --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-m4a-scrobble.md @@ -0,0 +1,2523 @@ +# M4a — ListenBrainz Outbound Scrobble Worker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Send the user's qualifying plays (≥240s OR ≥50% completion) to ListenBrainz with batching + exponential-backoff retry. Per-user token + enabled flag in the SPA's new `/settings` page. Backed by a `scrobble_queue` table that the worker drains every 30s. + +**Architecture:** New `internal/scrobble/` package tree: `listenbrainz/` for the pure HTTP client (typed errors for auth/permanent/transient/retry-after), and the parent package for `Qualifies`, `MaybeEnqueue`, and the `Worker` goroutine. `playevents.Writer.RecordPlayEnded` and `RecordSyntheticCompletedPlay` gain a post-commit best-effort enqueue call. New API endpoints `GET/PUT /api/me/listenbrainz` and a minimal `/settings` page in the SPA. + +**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 + TanStack Query. New SQL migration; no breaking changes to existing schema. + +**Reference:** design spec at `docs/superpowers/specs/2026-04-28-m4a-scrobble-design.md`. + +--- + +## File Structure + +**New server files:** + +| File | Responsibility | +|---|---| +| `internal/db/migrations/0008_scrobble.up.sql` + `.down.sql` | `users.listenbrainz_token`, `users.listenbrainz_enabled`, `scrobble_queue` table + partial index. | +| `internal/db/queries/users.sql` (modify) | Add `SetListenBrainzToken`, `SetListenBrainzEnabled`, `GetListenBrainzConfig`. | +| `internal/db/queries/scrobble.sql` | New: `EnqueueScrobble :execrows`, `ListPendingScrobbles`, `MarkScrobbleSent`, `MarkScrobbleFailed`, `RescheduleScrobble`, `MarkScrobbleRetryAfter`, `GetLastScrobbledForUser`. | +| `internal/db/dbq/scrobble.sql.go` | Generated bindings. | +| `internal/scrobble/listenbrainz/client.go` | Pure HTTP client. `Client`, `Listen`, `Track` types; `SubmitListens` method; typed errors `ErrAuth`, `ErrPermanent`, `ErrTransient`, `*RetryAfterError`. | +| `internal/scrobble/listenbrainz/client_test.go` | `httptest.Server`-driven tests for each error mapping + payload shape. | +| `internal/scrobble/threshold.go` | Pure `Qualifies(durationPlayedMs int, completionRatio float64) bool`. | +| `internal/scrobble/threshold_test.go` | Boundary tests. | +| `internal/scrobble/queue.go` | `MaybeEnqueue(ctx, q, playEventID)`. Reads user config + threshold; idempotent INSERT. | +| `internal/scrobble/queue_test.go` | Live-DB integration tests. | +| `internal/scrobble/worker.go` | `Worker` struct, `Run` loop, `tickOnce`, `backoffDelay` helper. | +| `internal/scrobble/worker_test.go` | Pure tests for `backoffDelay`. | +| `internal/scrobble/worker_integration_test.go` | Live-DB integration: full ticker pipeline against an httptest LB. | +| `internal/api/listenbrainz.go` | `handleGetListenBrainz`, `handlePutListenBrainz`. | +| `internal/api/listenbrainz_test.go` | Live-DB HTTP tests. | + +**Modified server files:** + +| File | Change | +|---|---| +| `internal/db/dbq/users.sql.go` + `models.go` | Generated additions for the new user columns. | +| `internal/playevents/writer.go::RecordPlayEnded` | After `pgx.BeginFunc` returns nil, call `scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID)`; log + swallow errors. | +| `internal/playevents/writer.go::RecordSyntheticCompletedPlay` | Same pattern. | +| `internal/playevents/writer_test.go` | Three new tests covering enqueue: qualifying play → row inserted, sub-threshold → no row, user-disabled → no row. | +| `internal/api/api.go::Mount` | Add `authed.Get("/me/listenbrainz", h.handleGetListenBrainz)` and `authed.Put("/me/listenbrainz", h.handlePutListenBrainz)`. | +| `internal/api/auth_test.go` | testHandlers helper unchanged (no new struct fields). | +| `cmd/minstrel/main.go` | Construct `scrobble.Worker` after pool open; `go worker.Run(ctx)` after server starts. | + +**Frontend files:** + +| File | Responsibility | +|---|---| +| `web/src/lib/api/client.ts` (modify) | Add `api.put(path, body)` helper. | +| `web/src/lib/api/listenbrainz.ts` | New: `getListenBrainzStatus()`, `setListenBrainzToken(token)`, `setListenBrainzEnabled(enabled)` + `LBStatus` type. | +| `web/src/routes/settings/+page.svelte` | The Settings page, single ListenBrainz section. | +| `web/src/routes/settings/settings.test.ts` | Vitest coverage for the page. | +| `web/src/lib/components/Shell.svelte` | Add `{ href: '/settings', label: 'Settings' }` to `navItems`. | +| `web/src/lib/components/Shell.test.ts` (modify) | Update nav assertions if the test enumerates items. | + +--- + +## Task 1: Migration 0008 — schema + +**Files:** +- Create: `internal/db/migrations/0008_scrobble.up.sql` +- Create: `internal/db/migrations/0008_scrobble.down.sql` + +- [ ] **Step 1: Write the up migration** + +Create `internal/db/migrations/0008_scrobble.up.sql`: + +```sql +-- M4a: outbound ListenBrainz scrobble worker. +-- Per-user LB config (plaintext token; users rotate via /settings if leaked). +-- scrobble_queue is a work list, not a log: successfully-sent rows are +-- DELETEd by the worker (the canonical record stays in play_events.scrobbled_at +-- via M2's column). Only `pending` and `failed` states exist. + +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) +); + +-- Hot path for the worker's per-tick query. +CREATE INDEX scrobble_queue_pending_idx + ON scrobble_queue (next_attempt_at) + WHERE status = 'pending'; +``` + +- [ ] **Step 2: Write the down migration** + +Create `internal/db/migrations/0008_scrobble.down.sql`: + +```sql +DROP TABLE IF EXISTS scrobble_queue; +ALTER TABLE users DROP COLUMN IF EXISTS listenbrainz_enabled; +ALTER TABLE users DROP COLUMN IF EXISTS listenbrainz_token; +``` + +- [ ] **Step 3: Verify the migration applies cleanly** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -short -race ./internal/db/` + +Expected: PASS. The migration test (which applies all migrations against a temp schema) finds version 8. + +- [ ] **Step 4: Commit** + +```bash +git add internal/db/migrations/0008_scrobble.up.sql internal/db/migrations/0008_scrobble.down.sql +git commit -m "feat(db): add migration 0008 for scrobble (users LB columns + scrobble_queue table)" +``` + +--- + +## Task 2: sqlc queries — users LB config + scrobble_queue + +**Files:** +- Modify: `internal/db/queries/users.sql` +- Create: `internal/db/queries/scrobble.sql` +- Generated: `internal/db/dbq/users.sql.go`, `internal/db/dbq/scrobble.sql.go`, `internal/db/dbq/models.go` + +- [ ] **Step 1: Add user-LB-config queries** + +Append to `internal/db/queries/users.sql`: + +```sql +-- name: SetListenBrainzToken :exec +UPDATE users +SET listenbrainz_token = $2, + listenbrainz_enabled = CASE WHEN COALESCE($2, '') = '' THEN FALSE ELSE listenbrainz_enabled END +WHERE id = $1; + +-- name: SetListenBrainzEnabled :exec +UPDATE users +SET listenbrainz_enabled = $2 +WHERE id = $1; + +-- name: GetListenBrainzConfig :one +-- Returns the user's LB token + enabled flag and the most recent +-- play_events.scrobbled_at for last-scrobbled-at status. +SELECT + u.listenbrainz_token, + u.listenbrainz_enabled, + (SELECT MAX(pe.scrobbled_at) + FROM play_events pe + WHERE pe.user_id = u.id) AS last_scrobbled_at +FROM users u +WHERE u.id = $1; +``` + +- [ ] **Step 2: Add scrobble_queue queries** + +Create `internal/db/queries/scrobble.sql`: + +```sql +-- name: EnqueueScrobble :execrows +-- Idempotent: if a row exists for this play_event_id, ON CONFLICT does nothing +-- and the affected-rows count is 0. Caller (MaybeEnqueue) doesn't treat 0 as +-- an error. +INSERT INTO scrobble_queue (user_id, play_event_id, status) +VALUES ($1, $2, 'pending') +ON CONFLICT (play_event_id) DO NOTHING; + +-- name: ListPendingScrobbles :many +-- Worker pulls up to N pending rows whose next_attempt_at has passed. +-- Joins play_events + tracks/albums/artists + users so the worker can +-- assemble the LB Listen payload in one round-trip. +SELECT + sq.id AS queue_id, + sq.user_id AS user_id, + sq.play_event_id AS play_event_id, + sq.attempts AS attempts, + u.listenbrainz_token AS lb_token, + u.listenbrainz_enabled AS lb_enabled, + pe.started_at AS started_at, + t.title AS track_title, + t.duration_ms AS track_duration_ms, + t.mbid AS track_mbid, + al.title AS album_title, + al.mbid AS album_mbid, + ar.name AS artist_name, + ar.mbid AS artist_mbid +FROM scrobble_queue sq +JOIN users u ON u.id = sq.user_id +JOIN play_events pe ON pe.id = sq.play_event_id +JOIN tracks t ON t.id = pe.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +WHERE sq.status = 'pending' + AND sq.next_attempt_at <= now() +ORDER BY sq.next_attempt_at +LIMIT $1; + +-- name: MarkScrobbleSent :exec +-- Successful submit. Delete the queue row AND stamp play_events.scrobbled_at +-- in one statement via a CTE so partial failure is impossible. +WITH del AS ( + DELETE FROM scrobble_queue + WHERE id = $1 + RETURNING play_event_id +) +UPDATE play_events +SET scrobbled_at = now() +WHERE id = (SELECT play_event_id FROM del); + +-- name: RescheduleScrobble :exec +-- After a transient failure: increment attempts, schedule next attempt, +-- record the error. +UPDATE scrobble_queue +SET attempts = attempts + 1, + next_attempt_at = $2, + last_error = $3 +WHERE id = $1; + +-- name: MarkScrobbleRetryAfter :exec +-- After a 429: schedule next attempt per LB's Retry-After header, but do NOT +-- increment attempts (the server told us to wait, not that we failed). +UPDATE scrobble_queue +SET next_attempt_at = $2 +WHERE id = $1; + +-- name: MarkScrobbleFailed :exec +-- After a permanent failure (4xx, exhausted retries, auth) — mark failed +-- and keep the row for diagnostics. +UPDATE scrobble_queue +SET status = 'failed', + last_error = $2 +WHERE id = $1; +``` + +- [ ] **Step 3: Run sqlc generate** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && make generate` + +Expected: docker pulls sqlc 1.27.0, regenerates `internal/db/dbq/`. New methods appear on `*Queries`: +- `SetListenBrainzToken`, `SetListenBrainzEnabled`, `GetListenBrainzConfig` +- `EnqueueScrobble`, `ListPendingScrobbles`, `MarkScrobbleSent`, `RescheduleScrobble`, `MarkScrobbleRetryAfter`, `MarkScrobbleFailed` + +`models.go` gains the two new `User` fields: `ListenbrainzToken *string` and `ListenbrainzEnabled bool`. + +- [ ] **Step 4: Verify compile** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` + +Expected: clean. No callers yet — generated bindings just need to compile. + +- [ ] **Step 5: Commit** + +```bash +git add internal/db/queries/ internal/db/dbq/ +git commit -m "feat(db): add ListenBrainz user-config and scrobble_queue queries" +``` + +--- + +## Task 3: ListenBrainz HTTP client (pure) + +**Files:** +- Create: `internal/scrobble/listenbrainz/client.go` +- Create: `internal/scrobble/listenbrainz/client_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/scrobble/listenbrainz/client_test.go`: + +```go +package listenbrainz + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) { + srv := httptest.NewServer(handler) + return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv +} + +func TestClient_SubmitListens_Success(t *testing.T) { + var seenPath, seenAuth, seenBody string + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenAuth = r.Header.Get("Authorization") + buf := make([]byte, 4096) + n, _ := r.Body.Read(buf) + seenBody = string(buf[:n]) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + defer srv.Close() + + listens := []Listen{{ + ListenedAt: 1700000000, + Track: Track{ + ArtistName: "Miles Davis", + TrackName: "So What", + ReleaseName: "Kind of Blue", + DurationMs: 545000, + }, + }} + if err := c.SubmitListens(context.Background(), "tk", listens); err != nil { + t.Fatalf("err: %v", err) + } + if seenPath != "/1/submit-listens" { + t.Errorf("path = %q, want /1/submit-listens", seenPath) + } + if seenAuth != "Token tk" { + t.Errorf("auth = %q, want %q", seenAuth, "Token tk") + } + if !strings.Contains(seenBody, `"track_name":"So What"`) { + t.Errorf("body missing track_name: %s", seenBody) + } + if !strings.Contains(seenBody, `"artist_name":"Miles Davis"`) { + t.Errorf("body missing artist_name: %s", seenBody) + } +} + +func TestClient_SubmitListens_401_ReturnsErrAuth(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer srv.Close() + err := c.SubmitListens(context.Background(), "bad", []Listen{{}}) + if !errors.Is(err, ErrAuth) { + t.Errorf("err = %v, want ErrAuth", err) + } +} + +func TestClient_SubmitListens_400_ReturnsErrPermanent(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }) + defer srv.Close() + err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) + if !errors.Is(err, ErrPermanent) { + t.Errorf("err = %v, want ErrPermanent", err) + } +} + +func TestClient_SubmitListens_503_ReturnsErrTransient(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }) + defer srv.Close() + err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) + if !errors.Is(err, ErrTransient) { + t.Errorf("err = %v, want ErrTransient", err) + } +} + +func TestClient_SubmitListens_429_ReturnsRetryAfter(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "60") + w.WriteHeader(http.StatusTooManyRequests) + }) + defer srv.Close() + err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) + var ra *RetryAfterError + if !errors.As(err, &ra) { + t.Fatalf("err = %v, want *RetryAfterError", err) + } + if ra.Wait != 60*time.Second { + t.Errorf("Wait = %v, want 60s", ra.Wait) + } +} + +func TestClient_SubmitListens_NetworkFailure_ReturnsErrTransient(t *testing.T) { + c := &Client{BaseURL: "http://127.0.0.1:1", HTTP: &http.Client{Timeout: 100 * time.Millisecond}} + err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) + if !errors.Is(err, ErrTransient) { + t.Errorf("err = %v, want ErrTransient", err) + } +} + +func TestClient_SubmitListens_PayloadTypeImportForBatch(t *testing.T) { + var seenBody string + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 4096) + n, _ := r.Body.Read(buf) + seenBody = string(buf[:n]) + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + _ = c.SubmitListens(context.Background(), "tk", []Listen{ + {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}}, + {ListenedAt: 2, Track: Track{ArtistName: "B", TrackName: "Y"}}, + }) + if !strings.Contains(seenBody, `"listen_type":"import"`) { + t.Errorf("batch body missing listen_type=import: %s", seenBody) + } +} + +func TestClient_SubmitListens_PayloadTypeSingleForOne(t *testing.T) { + var seenBody string + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 4096) + n, _ := r.Body.Read(buf) + seenBody = string(buf[:n]) + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + _ = c.SubmitListens(context.Background(), "tk", []Listen{ + {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}}, + }) + if !strings.Contains(seenBody, `"listen_type":"single"`) { + t.Errorf("single body missing listen_type=single: %s", seenBody) + } +} + +func TestClient_SubmitListens_OmitsEmptyMBIDs(t *testing.T) { + var seenBody string + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 4096) + n, _ := r.Body.Read(buf) + seenBody = string(buf[:n]) + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + _ = c.SubmitListens(context.Background(), "tk", []Listen{ + {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}}, + }) + if strings.Contains(seenBody, "recording_mbid") { + t.Errorf("body should omit empty recording_mbid: %s", seenBody) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -v` + +Expected: FAIL with "no Go files" or "undefined: Client". + +- [ ] **Step 3: Write the client** + +Create `internal/scrobble/listenbrainz/client.go`: + +```go +// Package listenbrainz is the pure HTTP client for the ListenBrainz +// submit-listens endpoint. No DB, no worker plumbing — just types and one +// method. Errors are typed (ErrAuth, ErrPermanent, ErrTransient, +// *RetryAfterError) so callers can branch on response semantics. +package listenbrainz + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "time" +) + +const defaultBaseURL = "https://api.listenbrainz.org" + +// Listen is one row sent to ListenBrainz. +type Listen struct { + ListenedAt int64 // unix seconds + Track Track +} + +// Track is the per-listen metadata. Optional fields are omitted from the +// payload when zero-valued. +type Track struct { + ArtistName string + TrackName string + ReleaseName string + DurationMs int + RecordingMBID string + ArtistMBIDs []string + ReleaseMBID string +} + +// Client posts to the LB submit-listens endpoint. +type Client struct { + BaseURL string + HTTP *http.Client +} + +// NewClient returns a default-configured client (LB production base URL, +// 30s timeout). +func NewClient() *Client { + return &Client{ + BaseURL: defaultBaseURL, + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +// Sentinel errors. The worker branches on these to decide retry vs fail. +var ( + ErrAuth = errors.New("listenbrainz: auth rejected") + ErrPermanent = errors.New("listenbrainz: permanent error") + ErrTransient = errors.New("listenbrainz: transient error") +) + +// RetryAfterError carries the LB-supplied wait duration for 429 responses. +type RetryAfterError struct{ Wait time.Duration } + +func (e *RetryAfterError) Error() string { + return fmt.Sprintf("listenbrainz: retry after %v", e.Wait) +} + +// SubmitListens posts the given listens. payload_type is "single" for one +// listen, "import" for batches. +func (c *Client) SubmitListens(ctx context.Context, token string, listens []Listen) error { + if len(listens) == 0 { + return nil + } + + listenType := "import" + if len(listens) == 1 { + listenType = "single" + } + + body, err := json.Marshal(buildPayload(listenType, listens)) + if err != nil { + return fmt.Errorf("listenbrainz: marshal: %w", err) + } + + base := c.BaseURL + if base == "" { + base = defaultBaseURL + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/1/submit-listens", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("listenbrainz: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Token "+token) + + httpClient := c.HTTP + if httpClient == nil { + httpClient = http.DefaultClient + } + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("%w: %v", ErrTransient, err) + } + defer func() { _ = resp.Body.Close() }() + + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return nil + case resp.StatusCode == http.StatusUnauthorized: + return ErrAuth + case resp.StatusCode == http.StatusTooManyRequests: + wait := parseRetryAfter(resp.Header.Get("Retry-After")) + return &RetryAfterError{Wait: wait} + case resp.StatusCode >= 500: + return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) + default: + return fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode) + } +} + +func parseRetryAfter(header string) time.Duration { + if header == "" { + return 60 * time.Second // sensible default + } + if secs, err := strconv.Atoi(header); err == nil { + return time.Duration(secs) * time.Second + } + if t, err := http.ParseTime(header); err == nil { + d := time.Until(t) + if d < 0 { + return 60 * time.Second + } + return d + } + return 60 * time.Second +} + +// payload mirrors the LB submit-listens body. Optional MBID fields use +// `omitempty` so empty strings/slices don't appear in the JSON. +type payload struct { + ListenType string `json:"listen_type"` + Payload []payloadEntry `json:"payload"` +} + +type payloadEntry struct { + ListenedAt int64 `json:"listened_at"` + TrackMetadata trackMetadata `json:"track_metadata"` +} + +type trackMetadata struct { + ArtistName string `json:"artist_name"` + TrackName string `json:"track_name"` + ReleaseName string `json:"release_name,omitempty"` + AdditionalInfo additionalInfo `json:"additional_info,omitempty"` +} + +type additionalInfo struct { + DurationMs int `json:"duration_ms,omitempty"` + RecordingMBID string `json:"recording_mbid,omitempty"` + ArtistMBIDs []string `json:"artist_mbids,omitempty"` + ReleaseMBID string `json:"release_mbid,omitempty"` +} + +func buildPayload(listenType string, listens []Listen) payload { + entries := make([]payloadEntry, 0, len(listens)) + for _, l := range listens { + entries = append(entries, payloadEntry{ + ListenedAt: l.ListenedAt, + TrackMetadata: trackMetadata{ + ArtistName: l.Track.ArtistName, + TrackName: l.Track.TrackName, + ReleaseName: l.Track.ReleaseName, + AdditionalInfo: additionalInfo{ + DurationMs: l.Track.DurationMs, + RecordingMBID: l.Track.RecordingMBID, + ArtistMBIDs: l.Track.ArtistMBIDs, + ReleaseMBID: l.Track.ReleaseMBID, + }, + }, + }) + } + return payload{ListenType: listenType, Payload: entries} +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -v` + +Expected: PASS for all 9 tests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/scrobble/listenbrainz/ +git commit -m "feat(scrobble): add pure ListenBrainz HTTP client with typed errors" +``` + +--- + +## Task 4: Threshold (pure) + +**Files:** +- Create: `internal/scrobble/threshold.go` +- Create: `internal/scrobble/threshold_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/scrobble/threshold_test.go`: + +```go +package scrobble + +import "testing" + +func TestQualifies_NeverPlayed(t *testing.T) { + if Qualifies(0, 0) { + t.Error("0/0 should not qualify") + } +} + +func TestQualifies_AtLeast240Seconds(t *testing.T) { + if !Qualifies(240_000, 0.0) { + t.Error("240_000 ms played should qualify regardless of ratio") + } +} + +func TestQualifies_AtLeastHalfRatio(t *testing.T) { + if !Qualifies(0, 0.5) { + t.Error("ratio=0.5 should qualify regardless of duration") + } +} + +func TestQualifies_BelowBoth(t *testing.T) { + if Qualifies(120_000, 0.49) { + t.Error("120s + 49% should not qualify") + } +} + +func TestQualifies_BoundaryJustUnder(t *testing.T) { + if Qualifies(239_999, 0.4999) { + t.Error("just-under both thresholds should not qualify") + } +} + +func TestQualifies_BoundaryExactly(t *testing.T) { + if !Qualifies(240_000, 0.4999) { + t.Error("exactly 240s should qualify") + } + if !Qualifies(239_999, 0.5) { + t.Error("exactly 50% should qualify") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run Qualifies -v` + +Expected: FAIL with "no Go files" or "undefined: Qualifies". + +- [ ] **Step 3: Write the threshold function** + +Create `internal/scrobble/threshold.go`: + +```go +// Package scrobble owns the outbound-scrobble pipeline: threshold detection, +// the queue write path, and the worker goroutine. The HTTP client is in +// internal/scrobble/listenbrainz; this package consumes it. +package scrobble + +// Qualifies returns true if a closed play_event meets ListenBrainz's +// recommended scrobble threshold: ≥240 seconds played OR ≥50% of the +// track length played. +// +// The two thresholds are deliberately separate from M2's skip-detection +// thresholds (which serve a different purpose — internal engine signal +// for the scoring formula's skip penalty). +func Qualifies(durationPlayedMs int, completionRatio float64) bool { + return durationPlayedMs >= 240_000 || completionRatio >= 0.5 +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run Qualifies -v` + +Expected: PASS for all 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/scrobble/threshold.go internal/scrobble/threshold_test.go +git commit -m "feat(scrobble): add pure Qualifies threshold function" +``` + +--- + +## Task 5: MaybeEnqueue (DB-backed) + +**Files:** +- Create: `internal/scrobble/queue.go` +- Create: `internal/scrobble/queue_test.go` + +- [ ] **Step 1: Write the failing integration tests** + +Create `internal/scrobble/queue_test.go`: + +```go +package scrobble + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func testPool(t *testing.T) (*pgxpool.Pool, *dbq.Queries) { + t.Helper() + if testing.Short() { + t.Skip("skipping in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(context.Background(), + "TRUNCATE scrobble_queue, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { + t.Fatalf("truncate: %v", err) + } + return pool, dbq.New(pool) +} + +type setup struct { + pool *pgxpool.Pool + q *dbq.Queries + user pgtype.UUID + playEvent pgtype.UUID +} + +// seed creates: user (with optional LB token + enabled), one artist/album/track, +// one closed play_event with the given duration_played_ms and completion_ratio. +func seed(t *testing.T, opts seedOpts) setup { + t.Helper() + pool, q := testPool(t) + ctx := context.Background() + u, err := q.CreateUser(ctx, dbq.CreateUserParams{ + Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + }) + if err != nil { + t.Fatalf("user: %v", err) + } + if opts.lbToken != "" { + if err := q.SetListenBrainzToken(ctx, dbq.SetListenBrainzTokenParams{ID: u.ID, ListenbrainzToken: &opts.lbToken}); err != nil { + t.Fatalf("token: %v", err) + } + } + if opts.lbEnabled { + if err := q.SetListenBrainzEnabled(ctx, dbq.SetListenBrainzEnabledParams{ID: u.ID, ListenbrainzEnabled: true}); err != nil { + t.Fatalf("enabled: %v", err) + } + } + a, _ := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "X", SortName: "X"}) + al, _ := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID}) + tr, _ := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/y.flac", DurationMs: 300_000, + }) + // Insert play_session + play_event directly with the provided duration/ratio. + var sessionID pgtype.UUID + if err := pool.QueryRow(ctx, + `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) + VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, + u.ID).Scan(&sessionID); err != nil { + t.Fatalf("session: %v", err) + } + var peID pgtype.UUID + if err := pool.QueryRow(ctx, + `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) + VALUES ($1, $2, $3, now() - interval '1 minute', now(), $4, $5, false) RETURNING id`, + u.ID, tr.ID, sessionID, opts.durationMs, opts.ratio).Scan(&peID); err != nil { + t.Fatalf("play_event: %v", err) + } + return setup{pool: pool, q: q, user: u.ID, playEvent: peID} +} + +type seedOpts struct { + lbToken string + lbEnabled bool + durationMs int32 + ratio float64 +} + +func countQueueRows(t *testing.T, s setup) int { + t.Helper() + var n int + if err := s.pool.QueryRow(context.Background(), + `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + return n +} + +func TestMaybeEnqueue_QualifyingPlayWithEnabledUser_InsertsRow(t *testing.T) { + s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) + if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { + t.Fatalf("MaybeEnqueue: %v", err) + } + if got := countQueueRows(t, s); got != 1 { + t.Errorf("rows = %d, want 1", got) + } +} + +func TestMaybeEnqueue_SubThreshold_NoRow(t *testing.T) { + // 100s, 33% — neither threshold met. + s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 100_000, ratio: 0.33}) + if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { + t.Fatalf("MaybeEnqueue: %v", err) + } + if got := countQueueRows(t, s); got != 0 { + t.Errorf("rows = %d, want 0 (sub-threshold)", got) + } +} + +func TestMaybeEnqueue_DisabledUser_NoRow(t *testing.T) { + s := seed(t, seedOpts{lbToken: "tk", lbEnabled: false, durationMs: 250_000, ratio: 0.83}) + if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { + t.Fatalf("MaybeEnqueue: %v", err) + } + if got := countQueueRows(t, s); got != 0 { + t.Errorf("rows = %d, want 0 (disabled)", got) + } +} + +func TestMaybeEnqueue_NoToken_NoRow(t *testing.T) { + // Even with enabled=true, an empty token shouldn't enqueue. + s := seed(t, seedOpts{lbToken: "", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) + if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { + t.Fatalf("MaybeEnqueue: %v", err) + } + if got := countQueueRows(t, s); got != 0 { + t.Errorf("rows = %d, want 0 (no token)", got) + } +} + +func TestMaybeEnqueue_Idempotent_TwoCallsOneRow(t *testing.T) { + s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) + for i := 0; i < 2; i++ { + if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { + t.Fatalf("MaybeEnqueue #%d: %v", i, err) + } + } + if got := countQueueRows(t, s); got != 1 { + t.Errorf("rows = %d, want 1 (idempotent)", got) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run MaybeEnqueue -v` + +Expected: FAIL with "undefined: MaybeEnqueue". + +- [ ] **Step 3: Write the implementation** + +Create `internal/scrobble/queue.go`: + +```go +package scrobble + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// 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. +// +// 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 { + pe, err := q.GetPlayEventByID(ctx, playEventID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + return err + } + // Only closed events qualify (DurationPlayedMs/CompletionRatio populated). + if pe.DurationPlayedMs == nil || pe.CompletionRatio == nil { + return nil + } + if !Qualifies(int(*pe.DurationPlayedMs), *pe.CompletionRatio) { + return nil + } + cfg, err := q.GetListenBrainzConfig(ctx, pe.UserID) + if err != nil { + return err + } + if !cfg.ListenbrainzEnabled || cfg.ListenbrainzToken == nil || *cfg.ListenbrainzToken == "" { + return nil + } + _, err = q.EnqueueScrobble(ctx, dbq.EnqueueScrobbleParams{ + UserID: pe.UserID, + PlayEventID: pe.ID, + }) + return err +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run MaybeEnqueue -v` + +Expected: PASS for all 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/scrobble/queue.go internal/scrobble/queue_test.go +git commit -m "feat(scrobble): add MaybeEnqueue with idempotent insert + threshold gate" +``` + +--- + +## Task 6: Backoff schedule (pure) + +**Files:** +- Create: `internal/scrobble/worker.go` (skeleton with backoffDelay only) +- Create: `internal/scrobble/worker_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/scrobble/worker_test.go`: + +```go +package scrobble + +import ( + "testing" + "time" +) + +func TestBackoffDelay(t *testing.T) { + cases := []struct { + attempts int + want time.Duration + ok bool + }{ + {1, 1 * time.Minute, true}, + {2, 5 * time.Minute, true}, + {3, 30 * time.Minute, true}, + {4, 2 * time.Hour, true}, + {5, 6 * time.Hour, true}, + {6, 0, false}, // exhausted + {99, 0, false}, // way past max + } + for _, c := range cases { + got, ok := backoffDelay(c.attempts) + if ok != c.ok { + t.Errorf("backoffDelay(%d) ok = %v, want %v", c.attempts, ok, c.ok) + } + if got != c.want { + t.Errorf("backoffDelay(%d) = %v, want %v", c.attempts, got, c.want) + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run BackoffDelay -v` + +Expected: FAIL with "undefined: backoffDelay". + +- [ ] **Step 3: Write the worker skeleton with backoffDelay** + +Create `internal/scrobble/worker.go`: + +```go +package scrobble + +import ( + "time" +) + +// backoffSchedule maps the failure count (1-indexed: 1 = first failure +// just happened) to the delay before the next attempt. Per spec §9.6: +// 1m → 5m → 30m → 2h → 6h, then give up. +var backoffSchedule = []time.Duration{ + 1 * time.Minute, + 5 * time.Minute, + 30 * time.Minute, + 2 * time.Hour, + 6 * time.Hour, +} + +const maxAttempts = 5 + +// backoffDelay returns the delay before the next attempt given the value +// of the `attempts` column AFTER the failure has been recorded (1-indexed). +// Returns (_, false) when attempts > maxAttempts (give up — the row should +// be marked failed). +func backoffDelay(attempts int) (time.Duration, bool) { + if attempts < 1 || attempts > maxAttempts { + return 0, false + } + return backoffSchedule[attempts-1], true +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run BackoffDelay -v` + +Expected: PASS for all cases. + +- [ ] **Step 5: Commit** + +```bash +git add internal/scrobble/worker.go internal/scrobble/worker_test.go +git commit -m "feat(scrobble): add backoffDelay schedule (1m, 5m, 30m, 2h, 6h, give up)" +``` + +--- + +## Task 7: Worker tickOnce (DB-backed) + +**Files:** +- Modify: `internal/scrobble/worker.go` (add Worker struct, NewWorker, tickOnce, Run) +- Create: `internal/scrobble/worker_integration_test.go` + +- [ ] **Step 1: Write the failing integration tests** + +Create `internal/scrobble/worker_integration_test.go`: + +```go +package scrobble + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" +) + +// helperEnqueue inserts a queue row directly so we don't go through the threshold path. +func helperEnqueue(t *testing.T, s setup) { + t.Helper() + if _, err := s.pool.Exec(context.Background(), + `INSERT INTO scrobble_queue (user_id, play_event_id, status) VALUES ($1, $2, 'pending')`, + s.user, s.playEvent); err != nil { + t.Fatalf("enqueue: %v", err) + } +} + +func helperPendingCount(t *testing.T, s setup) int { + t.Helper() + var n int + if err := s.pool.QueryRow(context.Background(), + `SELECT count(*) FROM scrobble_queue WHERE status = 'pending' AND play_event_id = $1`, s.playEvent).Scan(&n); err != nil { + t.Fatalf("pending: %v", err) + } + return n +} + +func helperFailedCount(t *testing.T, s setup) int { + t.Helper() + var n int + if err := s.pool.QueryRow(context.Background(), + `SELECT count(*) FROM scrobble_queue WHERE status = 'failed' AND play_event_id = $1`, s.playEvent).Scan(&n); err != nil { + t.Fatalf("failed: %v", err) + } + return n +} + +func helperPlayEventScrobbledAt(t *testing.T, s setup) bool { + t.Helper() + var v *time.Time + if err := s.pool.QueryRow(context.Background(), + `SELECT scrobbled_at FROM play_events WHERE id = $1`, s.playEvent).Scan(&v); err != nil { + t.Fatalf("scrobbled_at: %v", err) + } + return v != nil +} + +func newTestWorker(s setup, lb *listenbrainz.Client) *Worker { + return NewWorker(s.pool, lb, helperLogger()) +} + +func helperLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func TestWorker_Success_DeletesQueueRowAndStampsScrobbledAt(t *testing.T) { + s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) + helperEnqueue(t, s) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} + w := newTestWorker(s, lb) + + if err := w.tickOnce(context.Background()); err != nil { + t.Fatalf("tickOnce: %v", err) + } + if helperPendingCount(t, s) != 0 { + t.Error("queue row should be deleted on success") + } + if helperFailedCount(t, s) != 0 { + t.Error("no failed row expected on success") + } + if !helperPlayEventScrobbledAt(t, s) { + t.Error("play_events.scrobbled_at should be populated on success") + } +} + +func TestWorker_503Once_RowRemainsAttempts1(t *testing.T) { + s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) + helperEnqueue(t, s) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} + w := newTestWorker(s, lb) + _ = w.tickOnce(context.Background()) + + var attempts int + if err := s.pool.QueryRow(context.Background(), + `SELECT attempts FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&attempts); err != nil { + t.Fatalf("attempts: %v", err) + } + if attempts != 1 { + t.Errorf("attempts = %d, want 1 after 503", attempts) + } + if helperPendingCount(t, s) != 1 { + t.Error("row should remain pending after transient failure") + } +} + +func TestWorker_503FiveTimes_MarksFailed(t *testing.T) { + s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) + helperEnqueue(t, s) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} + w := newTestWorker(s, lb) + // Five failures: each tick marches next_attempt_at forward, so we need to + // reset it between ticks to simulate the time passing. + for i := 0; i < 5; i++ { + if _, err := s.pool.Exec(context.Background(), + `UPDATE scrobble_queue SET next_attempt_at = now() WHERE play_event_id = $1`, s.playEvent); err != nil { + t.Fatalf("reset: %v", err) + } + _ = w.tickOnce(context.Background()) + } + if helperFailedCount(t, s) != 1 { + t.Errorf("expected row marked failed after 5 attempts; pending=%d failed=%d", + helperPendingCount(t, s), helperFailedCount(t, s)) + } + var attempts int + var lastErr *string + _ = s.pool.QueryRow(context.Background(), + `SELECT attempts, last_error FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&attempts, &lastErr) + if attempts != 5 { + t.Errorf("attempts = %d, want 5", attempts) + } + if lastErr == nil || *lastErr == "" { + t.Error("last_error should be populated") + } +} + +func TestWorker_401_MarksFailedAndDisablesUser(t *testing.T) { + s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) + helperEnqueue(t, s) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} + w := newTestWorker(s, lb) + _ = w.tickOnce(context.Background()) + + if helperFailedCount(t, s) != 1 { + t.Error("401 should mark failed immediately") + } + cfg, _ := s.q.GetListenBrainzConfig(context.Background(), s.user) + if cfg.ListenbrainzEnabled { + t.Error("user listenbrainz_enabled should be set to false on auth failure") + } +} + +func TestWorker_429_RespectsRetryAfterWithoutIncrementingAttempts(t *testing.T) { + s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) + helperEnqueue(t, s) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "300") + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} + w := newTestWorker(s, lb) + _ = w.tickOnce(context.Background()) + + var attempts int + var nextAttemptAt time.Time + _ = s.pool.QueryRow(context.Background(), + `SELECT attempts, next_attempt_at FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent). + Scan(&attempts, &nextAttemptAt) + if attempts != 0 { + t.Errorf("attempts = %d, want 0 (429 should not increment)", attempts) + } + if d := time.Until(nextAttemptAt); d < 250*time.Second || d > 350*time.Second { + t.Errorf("next_attempt_at = +%v, want ~+5m (Retry-After 300)", d) + } +} + +func TestWorker_BatchOfTen_OnePost(t *testing.T) { + s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) + // Enqueue 10 distinct play_events to test batching. + for i := 0; i < 10; i++ { + var newPE pgtype.UUID + if err := s.pool.QueryRow(context.Background(), + `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) + SELECT user_id, track_id, session_id, now(), now(), duration_played_ms, completion_ratio, was_skipped FROM play_events WHERE id = $1 + RETURNING id`, s.playEvent).Scan(&newPE); err != nil { + t.Fatalf("dup play_event: %v", err) + } + if _, err := s.pool.Exec(context.Background(), + `INSERT INTO scrobble_queue (user_id, play_event_id, status) VALUES ($1, $2, 'pending')`, + s.user, newPE); err != nil { + t.Fatalf("enqueue: %v", err) + } + } + var posts atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + posts.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} + w := newTestWorker(s, lb) + _ = w.tickOnce(context.Background()) + + if posts.Load() != 1 { + t.Errorf("LB POSTs = %d, want 1 (batch)", posts.Load()) + } +} +``` + +Note: this test file imports `io`, `log/slog`, `pgtype` — make sure to add those imports at the top of the file. The exact import block: + +```go +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" +) +``` + +(The `errors` and `strings` imports may be unused — remove if Go compiler complains.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run Worker -v` + +Expected: FAIL with "undefined: NewWorker" and "undefined: tickOnce". + +- [ ] **Step 3: Implement Worker, NewWorker, tickOnce, Run** + +Replace `internal/scrobble/worker.go` contents: + +```go +package scrobble + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" +) + +// backoffSchedule maps the failure count (1-indexed) to the delay before +// the next attempt. Per spec §9.6: 1m → 5m → 30m → 2h → 6h, then give up. +var backoffSchedule = []time.Duration{ + 1 * time.Minute, + 5 * time.Minute, + 30 * time.Minute, + 2 * time.Hour, + 6 * time.Hour, +} + +const maxAttempts = 5 + +// backoffDelay returns the delay before the next attempt given the value +// of the `attempts` column AFTER the failure has been recorded (1-indexed). +func backoffDelay(attempts int) (time.Duration, bool) { + if attempts < 1 || attempts > maxAttempts { + return 0, false + } + return backoffSchedule[attempts-1], true +} + +// Worker drains scrobble_queue rows and POSTs them to ListenBrainz. +type Worker struct { + pool *pgxpool.Pool + client *listenbrainz.Client + logger *slog.Logger + tick time.Duration + batch int32 +} + +// NewWorker constructs a worker with production defaults: 30s tick, 50-row +// batch. +func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker { + return &Worker{pool: pool, client: client, logger: logger, tick: 30 * time.Second, batch: 50} +} + +// Run blocks until ctx is cancelled, ticking every w.tick. +func (w *Worker) Run(ctx context.Context) { + t := time.NewTicker(w.tick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := w.tickOnce(ctx); err != nil { + w.logger.Error("scrobble: tick failed", "err", err) + } + } + } +} + +// tickOnce drains up to w.batch pending rows. Returns the first error +// encountered loading rows; per-row errors are logged and don't abort +// the batch. +func (w *Worker) tickOnce(ctx context.Context) error { + q := dbq.New(w.pool) + rows, err := q.ListPendingScrobbles(ctx, w.batch) + if err != nil { + return err + } + if len(rows) == 0 { + return nil + } + + // Group rows by user (each user has their own token + may produce its + // own batch). + type userBatch struct { + token string + queueIDs []pgtype.UUID + listens []listenbrainz.Listen + userID pgtype.UUID + playIDs []pgtype.UUID + } + batches := map[string]*userBatch{} + for _, r := range rows { + if r.LbToken == nil || *r.LbToken == "" || !r.LbEnabled { + // Defensive: row got into queue but user since disabled. Skip. + continue + } + key := r.UserID.String() + ub := batches[key] + if ub == nil { + ub = &userBatch{token: *r.LbToken, userID: r.UserID} + batches[key] = ub + } + ub.queueIDs = append(ub.queueIDs, r.QueueID) + ub.playIDs = append(ub.playIDs, r.PlayEventID) + l := listenbrainz.Listen{ + ListenedAt: r.StartedAt.Time.Unix(), + Track: listenbrainz.Track{ + ArtistName: r.ArtistName, + TrackName: r.TrackTitle, + ReleaseName: r.AlbumTitle, + DurationMs: int(r.TrackDurationMs), + }, + } + if r.TrackMbid != nil { + l.Track.RecordingMBID = *r.TrackMbid + } + if r.AlbumMbid != nil { + l.Track.ReleaseMBID = *r.AlbumMbid + } + if r.ArtistMbid != nil { + l.Track.ArtistMBIDs = []string{*r.ArtistMbid} + } + ub.listens = append(ub.listens, l) + } + + for _, ub := range batches { + w.processBatch(ctx, q, ub.userID, ub.token, ub.queueIDs, ub.listens) + } + return nil +} + +// processBatch submits one user's pending batch and updates queue rows +// according to the response. +func (w *Worker) processBatch( + ctx context.Context, + q *dbq.Queries, + userID pgtype.UUID, + token string, + queueIDs []pgtype.UUID, + listens []listenbrainz.Listen, +) { + err := w.client.SubmitListens(ctx, token, listens) + now := time.Now().UTC() + + if err == nil { + for _, id := range queueIDs { + if uerr := q.MarkScrobbleSent(ctx, id); uerr != nil { + w.logger.Error("scrobble: MarkScrobbleSent", "queue_id", id, "err", uerr) + } + } + return + } + + // 429: schedule retry per Retry-After, do NOT increment attempts. + var ra *listenbrainz.RetryAfterError + if errors.As(err, &ra) { + next := pgtype.Timestamptz{Time: now.Add(ra.Wait), Valid: true} + for _, id := range queueIDs { + if uerr := q.MarkScrobbleRetryAfter(ctx, dbq.MarkScrobbleRetryAfterParams{ + ID: id, NextAttemptAt: next, + }); uerr != nil { + w.logger.Error("scrobble: MarkScrobbleRetryAfter", "err", uerr) + } + } + return + } + + // 401: mark failed AND disable user (defensive — bad token shouldn't keep + // retrying or feed future rows). + if errors.Is(err, listenbrainz.ErrAuth) { + w.logger.Warn("scrobble: auth rejected; disabling user listenbrainz", "user_id", userID) + if uerr := q.SetListenBrainzEnabled(ctx, dbq.SetListenBrainzEnabledParams{ + ID: userID, ListenbrainzEnabled: false, + }); uerr != nil { + w.logger.Error("scrobble: SetListenBrainzEnabled", "err", uerr) + } + errStr := err.Error() + for _, id := range queueIDs { + if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{ + ID: id, LastError: &errStr, + }); uerr != nil { + w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr) + } + } + return + } + + // 4xx: permanent. Mark failed, no retry. + if errors.Is(err, listenbrainz.ErrPermanent) { + errStr := err.Error() + for _, id := range queueIDs { + if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{ + ID: id, LastError: &errStr, + }); uerr != nil { + w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr) + } + } + return + } + + // Transient (5xx, network): increment attempts, schedule next or give up. + // Each row in the batch shares the same fate since they were submitted + // together — we update each independently using its current attempts+1. + errStr := err.Error() + for _, id := range queueIDs { + // Read current attempts to compute next state. + var attempts int32 + if rerr := w.pool.QueryRow(ctx, + `SELECT attempts FROM scrobble_queue WHERE id = $1`, id).Scan(&attempts); rerr != nil { + w.logger.Error("scrobble: read attempts", "err", rerr) + continue + } + next := attempts + 1 + if delay, ok := backoffDelay(int(next)); ok { + nextAt := pgtype.Timestamptz{Time: now.Add(delay), Valid: true} + if uerr := q.RescheduleScrobble(ctx, dbq.RescheduleScrobbleParams{ + ID: id, NextAttemptAt: nextAt, LastError: &errStr, + }); uerr != nil { + w.logger.Error("scrobble: RescheduleScrobble", "err", uerr) + } + } else { + if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{ + ID: id, LastError: &errStr, + }); uerr != nil { + w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr) + } + } + } +} +``` + +- [ ] **Step 4: Run all scrobble tests** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/... -v` + +Expected: PASS for all worker tests + previously-passing tests in `listenbrainz/`, `threshold_test.go`, `queue_test.go`, `worker_test.go`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/scrobble/worker.go internal/scrobble/worker_integration_test.go +git commit -m "feat(scrobble): add Worker with tickOnce, batched submit, retry/fail/auth handling" +``` + +--- + +## Task 8: Hook MaybeEnqueue into playevents.Writer + +**Files:** +- Modify: `internal/playevents/writer.go` +- Modify: `internal/playevents/writer_test.go` (or a sibling file, depending on existing structure) + +- [ ] **Step 1: Write the failing test** + +Append to `internal/playevents/writer_test.go` (or create one if missing): + +```go +func TestRecordPlayEnded_QualifyingPlay_EnqueuesScrobble(t *testing.T) { + if testing.Short() { + t.Skip("integration test") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + pool, q := newTestPool(t, dsn) // assume helper from existing writer_test.go + defer pool.Close() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + w := NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) + + user, track := seedUserAndTrack(t, q, "tester", 300_000) // helper, set duration=300s + + // Enable LB. + tk := "tk" + if err := q.SetListenBrainzToken(context.Background(), dbq.SetListenBrainzTokenParams{ID: user.ID, ListenbrainzToken: &tk}); err != nil { + t.Fatalf("token: %v", err) + } + if err := q.SetListenBrainzEnabled(context.Background(), dbq.SetListenBrainzEnabledParams{ID: user.ID, ListenbrainzEnabled: true}); err != nil { + t.Fatalf("enable: %v", err) + } + + // Start + end with 250s played (qualifies). + res, err := w.RecordPlayStarted(context.Background(), user.ID, track.ID, "test", time.Now().Add(-1*time.Minute)) + if err != nil { + t.Fatalf("start: %v", err) + } + if err := w.RecordPlayEnded(context.Background(), res.PlayEventID, 250_000, time.Now()); err != nil { + t.Fatalf("end: %v", err) + } + + // Allow the post-commit goroutine (if any) to settle. Currently MaybeEnqueue + // is called synchronously after BeginFunc returns, so a sleep isn't needed — + // but if we ever switch to a goroutine, this gives it time. + time.Sleep(50 * time.Millisecond) + + var n int + if err := pool.QueryRow(context.Background(), + `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, res.PlayEventID).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Errorf("scrobble_queue rows = %d, want 1", n) + } +} + +func TestRecordPlayEnded_SubThreshold_NoEnqueue(t *testing.T) { + if testing.Short() { + t.Skip("integration test") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + pool, q := newTestPool(t, dsn) + defer pool.Close() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + w := NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) + user, track := seedUserAndTrack(t, q, "tester", 300_000) + + tk := "tk" + _ = q.SetListenBrainzToken(context.Background(), dbq.SetListenBrainzTokenParams{ID: user.ID, ListenbrainzToken: &tk}) + _ = q.SetListenBrainzEnabled(context.Background(), dbq.SetListenBrainzEnabledParams{ID: user.ID, ListenbrainzEnabled: true}) + + res, _ := w.RecordPlayStarted(context.Background(), user.ID, track.ID, "test", time.Now().Add(-1*time.Minute)) + _ = w.RecordPlayEnded(context.Background(), res.PlayEventID, 60_000, time.Now()) // 20%, well below threshold + + var n int + _ = pool.QueryRow(context.Background(), + `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, res.PlayEventID).Scan(&n) + if n != 0 { + t.Errorf("expected 0 queue rows for sub-threshold play, got %d", n) + } +} +``` + +If `seedUserAndTrack` and `newTestPool` aren't already in `writer_test.go`, look for the existing fixture functions (the existing tests must use something equivalent) and reuse those. If they're truly absent, write minimal versions following the pattern from `internal/scrobble/queue_test.go::seed`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/playevents/ -run RecordPlayEnded.Scrobble -v` + +Expected: tests fail because no enqueue hook exists yet. + +- [ ] **Step 3: Add the post-commit enqueue hook** + +In `internal/playevents/writer.go`, modify `RecordPlayEnded` to add the post-commit `MaybeEnqueue` call: + +```go +import ( + // existing imports … + "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" +) + +func (w *Writer) RecordPlayEnded( + ctx context.Context, + playEventID pgtype.UUID, + durationPlayedMs int32, + at time.Time, +) error { + if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error { + // … existing close logic … + }); err != nil { + return err + } + + // Post-commit best-effort scrobble enqueue. Errors logged + swallowed: + // scrobble delivery is enrichment, not a precondition for canonical play + // history. + if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil { + w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err) + } + return nil +} +``` + +Apply the **same pattern** to `RecordSyntheticCompletedPlay`: keep the existing `pgx.BeginFunc`, capture the inserted play_event ID, and after the commit call `scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), evID)`. The existing function already creates and ends the play_event in one transaction; you'll need to surface the ID up out of the closure into a local variable before the post-commit call. + +The exact diff for `RecordSyntheticCompletedPlay`: + +```go +func (w *Writer) RecordSyntheticCompletedPlay( + ctx context.Context, + userID, trackID pgtype.UUID, + clientID string, + at time.Time, +) error { + var playEventID pgtype.UUID + if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error { + // … existing logic, but assign ev.ID to playEventID before returning … + // e.g. after `ev, err := q.InsertPlayEvent(...)` succeeds: + // playEventID = ev.ID + }); err != nil { + return err + } + if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil { + w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err) + } + return nil +} +``` + +(Inspect the current `RecordSyntheticCompletedPlay` body and add `playEventID = ev.ID` immediately after `q.InsertPlayEvent` returns. The function ends the play_event in the same transaction; a single playEventID variable captured pre-commit is correct.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/playevents/ -v` + +Expected: PASS for all existing tests + 2 new scrobble-enqueue tests. + +- [ ] **Step 5: Verify Subsonic scrobble path also passes** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/subsonic/ -v` + +Expected: PASS. Pre-existing scrobble integration tests still pass; the post-commit enqueue is a no-op for users without LB enabled. + +- [ ] **Step 6: Commit** + +```bash +git add internal/playevents/writer.go internal/playevents/writer_test.go +git commit -m "feat(playevents): post-commit MaybeEnqueue in RecordPlayEnded + Synthetic" +``` + +--- + +## Task 9: API endpoints — GET/PUT /api/me/listenbrainz + +**Files:** +- Create: `internal/api/listenbrainz.go` +- Create: `internal/api/listenbrainz_test.go` +- Modify: `internal/api/api.go::Mount` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/api/listenbrainz_test.go`: + +```go +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +type lbStatusJSON struct { + Enabled bool `json:"enabled"` + TokenSet bool `json:"token_set"` + LastScrobbledAt *string `json:"last_scrobbled_at"` +} + +func callGetLB(h *handlers, user dbq.User) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/me/listenbrainz", nil) + req = req.WithContext(auth.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + h.handleGetListenBrainz(w, req) + return w +} + +func callPutLB(h *handlers, user dbq.User, body any) *httptest.ResponseRecorder { + buf, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/api/me/listenbrainz", bytes.NewReader(buf)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(auth.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + h.handlePutListenBrainz(w, req) + return w +} + +func TestHandleGetListenBrainz_NoTokenInitial(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + u := seedUser(t, pool, "alice", "x", false) + w := callGetLB(h, u) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var resp lbStatusJSON + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.Enabled || resp.TokenSet || resp.LastScrobbledAt != nil { + t.Errorf("initial state wrong: %+v", resp) + } +} + +func TestHandlePutListenBrainz_SetTokenThenGet(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + u := seedUser(t, pool, "alice", "x", false) + + put := callPutLB(h, u, map[string]any{"token": "abc"}) + if put.Code != http.StatusOK { + t.Fatalf("PUT status = %d body = %s", put.Code, put.Body.String()) + } + + get := callGetLB(h, u) + var resp lbStatusJSON + _ = json.Unmarshal(get.Body.Bytes(), &resp) + if !resp.TokenSet { + t.Error("TokenSet = false after PUT, want true") + } +} + +func TestHandlePutListenBrainz_ClearTokenForcesEnabledFalse(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + u := seedUser(t, pool, "alice", "x", false) + + _ = callPutLB(h, u, map[string]any{"token": "abc"}) + _ = callPutLB(h, u, map[string]any{"enabled": true}) + clear := callPutLB(h, u, map[string]any{"token": ""}) + if clear.Code != http.StatusOK { + t.Fatalf("clear status = %d", clear.Code) + } + + get := callGetLB(h, u) + var resp lbStatusJSON + _ = json.Unmarshal(get.Body.Bytes(), &resp) + if resp.TokenSet { + t.Error("TokenSet = true after clearing") + } + if resp.Enabled { + t.Error("Enabled = true after clearing token, want false (force-cleared)") + } +} + +func TestHandlePutListenBrainz_EnableWithoutToken_400(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + u := seedUser(t, pool, "alice", "x", false) + resp := callPutLB(h, u, map[string]any{"enabled": true}) + if resp.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (cannot enable without token)", resp.Code) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -run ListenBrainz -v` + +Expected: FAIL — handlers undefined. + +- [ ] **Step 3: Implement the handlers** + +Create `internal/api/listenbrainz.go`: + +```go +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/jackc/pgx/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// LBStatus is the GET response body and a subset of the PUT response. +type LBStatus struct { + Enabled bool `json:"enabled"` + TokenSet bool `json:"token_set"` + LastScrobbledAt *string `json:"last_scrobbled_at"` +} + +func (h *handlers) handleGetListenBrainz(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + resp, err := buildLBStatus(r, h, user.ID) + if err != nil { + h.logger.Error("api: get listenbrainz", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + writeJSON(w, http.StatusOK, resp) +} + +type putLBBody struct { + Token *string `json:"token,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +func (h *handlers) handlePutListenBrainz(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + var body putLBBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid json") + return + } + + q := dbq.New(h.pool) + if body.Token != nil { + t := *body.Token + var tokenPtr *string + if t != "" { + tokenPtr = &t + } // empty string clears via SetListenBrainzToken's COALESCE handling + if err := q.SetListenBrainzToken(r.Context(), dbq.SetListenBrainzTokenParams{ + ID: user.ID, ListenbrainzToken: tokenPtr, + }); err != nil { + h.logger.Error("api: set listenbrainz token", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "update failed") + return + } + } + if body.Enabled != nil && *body.Enabled { + // Enabling requires a non-empty token. + cfg, err := q.GetListenBrainzConfig(r.Context(), user.ID) + if err != nil { + h.logger.Error("api: get listenbrainz config", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + if cfg.ListenbrainzToken == nil || *cfg.ListenbrainzToken == "" { + writeErr(w, http.StatusBadRequest, "bad_request", "cannot enable without a token") + return + } + } + if body.Enabled != nil { + if err := q.SetListenBrainzEnabled(r.Context(), dbq.SetListenBrainzEnabledParams{ + ID: user.ID, ListenbrainzEnabled: *body.Enabled, + }); err != nil { + h.logger.Error("api: set listenbrainz enabled", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "update failed") + return + } + } + + resp, err := buildLBStatus(r, h, user.ID) + if err != nil { + h.logger.Error("api: put listenbrainz refresh status", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + writeJSON(w, http.StatusOK, resp) +} + +func buildLBStatus(r *http.Request, h *handlers, userID pgtypeUUID) (LBStatus, error) { + q := dbq.New(h.pool) + cfg, err := q.GetListenBrainzConfig(r.Context(), userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return LBStatus{}, nil + } + return LBStatus{}, err + } + out := LBStatus{ + Enabled: cfg.ListenbrainzEnabled, + TokenSet: cfg.ListenbrainzToken != nil && *cfg.ListenbrainzToken != "", + } + if cfg.LastScrobbledAt.Valid { + s := cfg.LastScrobbledAt.Time.UTC().Format(time.RFC3339) + out.LastScrobbledAt = &s + } + return out, nil +} +``` + +Note: `pgtypeUUID` is shorthand here — replace with the actual `pgtype.UUID` import. Add `"github.com/jackc/pgx/v5/pgtype"` and `"time"` to the imports. Update the function signature: `func buildLBStatus(r *http.Request, h *handlers, userID pgtype.UUID) (LBStatus, error)`. + +- [ ] **Step 4: Mount the routes** + +In `internal/api/api.go::Mount`, add inside the `authed.Group(...)` block (after the existing `/me` route): + +```go +authed.Get("/me/listenbrainz", h.handleGetListenBrainz) +authed.Put("/me/listenbrainz", h.handlePutListenBrainz) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -run ListenBrainz -v` + +Expected: PASS for all 4 tests. + +- [ ] **Step 6: Commit** + +```bash +git add internal/api/listenbrainz.go internal/api/listenbrainz_test.go internal/api/api.go +git commit -m "feat(api): add GET/PUT /api/me/listenbrainz endpoints" +``` + +--- + +## Task 10: Boot the worker in main.go + +**Files:** +- Modify: `cmd/minstrel/main.go` + +- [ ] **Step 1: Wire the worker startup** + +In `cmd/minstrel/main.go`, add the imports: + +```go +"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" +"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" +``` + +After the `pool` is opened (after `pool, err := db.Open(...)` and the `defer pool.Close()`), and before the HTTP server starts, add: + +```go +// Start the ListenBrainz scrobble worker. Per spec §M4a, runs every 30s, +// drains up to 50 pending rows per tick. Per-user gating happens inside the +// worker (rows from disabled users are skipped). +scrobbleWorker := scrobble.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "scrobble")) +go scrobbleWorker.Run(ctx) +``` + +- [ ] **Step 2: Verify compile** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` + +Expected: clean. + +- [ ] **Step 3: Verify the worker actually starts** + +Run the binary briefly and check the logs for the scrobble worker: + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && SMARTMUSIC_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' SMARTMUSIC_LIBRARY_SCAN_PATHS='' SMARTMUSIC_LIBRARY_SCAN_ON_STARTUP=false timeout 5 go run ./cmd/minstrel/ 2>&1 | head -20` + +Expected: server starts, no panic, worker tick logs (or absence of error logs) demonstrate Run is alive. + +- [ ] **Step 4: Commit** + +```bash +git add cmd/minstrel/main.go +git commit -m "feat(cmd): start scrobble worker alongside HTTP server" +``` + +--- + +## Task 11: Frontend — apiPut + listenbrainz API helpers + /settings page + +**Files:** +- Modify: `web/src/lib/api/client.ts` +- Create: `web/src/lib/api/listenbrainz.ts` +- Create: `web/src/routes/settings/+page.svelte` +- Create: `web/src/routes/settings/settings.test.ts` +- Modify: `web/src/lib/components/Shell.svelte` + +- [ ] **Step 1: Add `api.put` helper** + +In `web/src/lib/api/client.ts`, add a `put` method to the `api` object. The existing object looks like: + +```ts +export const api = { + get: (path: string): Promise => apiFetch(path) as Promise, + post: (path: string, body: unknown): Promise => + apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, + del: (path: string): Promise => + apiFetch(path, { method: 'DELETE' }) as Promise +}; +``` + +Replace with: + +```ts +export const api = { + get: (path: string): Promise => apiFetch(path) as Promise, + post: (path: string, body: unknown): Promise => + apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, + put: (path: string, body: unknown): Promise => + apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise, + del: (path: string): Promise => + apiFetch(path, { method: 'DELETE' }) as Promise +}; +``` + +- [ ] **Step 2: Add ListenBrainz API helpers** + +Create `web/src/lib/api/listenbrainz.ts`: + +```ts +import { api } from './client'; + +export type LBStatus = { + enabled: boolean; + token_set: boolean; + last_scrobbled_at: string | null; +}; + +export function getListenBrainzStatus(): Promise { + return api.get('/api/me/listenbrainz'); +} + +export function setListenBrainzToken(token: string): Promise { + return api.put('/api/me/listenbrainz', { token }); +} + +export function setListenBrainzEnabled(enabled: boolean): Promise { + return api.put('/api/me/listenbrainz', { enabled }); +} +``` + +- [ ] **Step 3: Create the Settings page** + +Create `web/src/routes/settings/+page.svelte`: + +```svelte + + +
+

Settings

+ +
+

ListenBrainz

+ + {#if $status.isPending} +

Loading…

+ {:else if $status.isError} +

Couldn't load status.

+ {:else if $status.data} +
+ + + + + {#if $status.data.last_scrobbled_at} +

+ Last scrobbled: {new Date($status.data.last_scrobbled_at).toLocaleString()} +

+ {/if} + +

+ Get a token at listenbrainz.org/profile. + Tokens are stored unencrypted in this server's database — treat as sensitive. +

+
+ {/if} +
+
+``` + +- [ ] **Step 4: Add Settings to the nav** + +In `web/src/lib/components/Shell.svelte`, in the `navItems` array: + +```ts +const navItems = [ + { href: '/', label: 'Library' }, + { href: '/library/liked', label: 'Liked' }, + { href: '/search', label: 'Search' }, + { href: '/playlists', label: 'Playlists' }, + { href: '/settings', label: 'Settings' } +]; +``` + +- [ ] **Step 5: Write the failing settings tests** + +Create `web/src/routes/settings/settings.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; +import { createRawSnippet } from 'svelte'; + +vi.mock('$lib/api/listenbrainz', () => ({ + getListenBrainzStatus: vi.fn(), + setListenBrainzToken: vi.fn(), + setListenBrainzEnabled: vi.fn() +})); + +import SettingsPage from './+page.svelte'; +import { + getListenBrainzStatus, + setListenBrainzToken, + setListenBrainzEnabled +} from '$lib/api/listenbrainz'; + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(QueryClientProvider, { + props: { + client: qc, + children: createRawSnippet(() => ({ render: () => '
' })) + } + }); + // NOTE: The above is a sketch; actual integration into your QueryClientProvider + // wrapping should match the pattern used in /library/liked tests. See + // web/src/routes/library/liked/liked.test.ts for the canonical example. +} +``` + +In practice, follow the pattern used by `web/src/routes/library/liked/liked.test.ts` (which already wraps a route with `QueryClientProvider`) — copy that boilerplate. Replace the test cases with the SearchBox tests below: + +```ts +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('Settings page — ListenBrainz', () => { + test('token-not-set state renders input + Save button', async () => { + (getListenBrainzStatus as any).mockResolvedValue({ + enabled: false, token_set: false, last_scrobbled_at: null + }); + renderPage(); + await waitFor(() => expect(screen.getByPlaceholderText(/paste your lb token/i)).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + test('token-set state renders masked + Clear button', async () => { + (getListenBrainzStatus as any).mockResolvedValue({ + enabled: false, token_set: true, last_scrobbled_at: null + }); + renderPage(); + await waitFor(() => expect(screen.getByText(/\(set\)/)).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /clear/i })).toBeInTheDocument(); + }); + + test('Save button calls setListenBrainzToken with typed value', async () => { + (getListenBrainzStatus as any).mockResolvedValue({ + enabled: false, token_set: false, last_scrobbled_at: null + }); + (setListenBrainzToken as any).mockResolvedValue({ enabled: false, token_set: true, last_scrobbled_at: null }); + renderPage(); + const input = await screen.findByPlaceholderText(/paste your lb token/i); + await fireEvent.input(input, { target: { value: 'mytoken' } }); + await fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => expect(setListenBrainzToken).toHaveBeenCalledWith('mytoken')); + }); + + test('Enabled checkbox is disabled when no token', async () => { + (getListenBrainzStatus as any).mockResolvedValue({ + enabled: false, token_set: false, last_scrobbled_at: null + }); + renderPage(); + const checkbox = await screen.findByRole('checkbox'); + expect(checkbox).toBeDisabled(); + }); + + test('Last-scrobbled-at renders when present', async () => { + (getListenBrainzStatus as any).mockResolvedValue({ + enabled: true, + token_set: true, + last_scrobbled_at: '2026-04-28T03:00:00Z' + }); + renderPage(); + await waitFor(() => expect(screen.getByText(/last scrobbled:/i)).toBeInTheDocument()); + }); +}); +``` + +- [ ] **Step 6: Run web tests + check + build** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check 2>&1 | tail -3 && npm test -- --run src/routes/settings 2>&1 | tail -10` + +Expected: type-check clean; 5 settings tests pass. + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run 2>&1 | tail -5` + +Expected: full vitest suite passes (175 + 5 new = 180). + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run build 2>&1 | tail -5` + +Expected: adapter-static emits `web/build/`. + +- [ ] **Step 7: Commit** + +```bash +git add web/ +git commit -m "feat(web): add /settings page with ListenBrainz section" +``` + +--- + +## Task 12: Final verification + branch finish + +**Files:** none (verification only) + +- [ ] **Step 1: Full Go test suite (-short -race)** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test -short -race ./...` + +Expected: PASS across all 14 packages (now including `internal/scrobble` and `internal/scrobble/listenbrainz`). + +- [ ] **Step 2: Full integration suite** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race -p 1 ./...` + +Expected: PASS apart from the pre-existing `TestScanner_Integration` flake (CI runs `-short` which skips it; not introduced by this PR). + +- [ ] **Step 3: Lint** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` + +Expected: clean. + +- [ ] **Step 4: Coverage check on new package** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -p 1 -coverprofile=/tmp/cover-m4a.out ./internal/scrobble/... && go tool cover -func=/tmp/cover-m4a.out | tail -5` + +Expected: `internal/scrobble` and `internal/scrobble/listenbrainz` combined coverage ≥ 80%. + +- [ ] **Step 5: Web verification** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check && npm test -- --run && npm run build` + +Expected: svelte-check 0/0; 180 vitest tests pass (was 175); build succeeds. + +- [ ] **Step 6: Docker smoke** + +Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && docker build -t minstrel:m4a-smoke .` + +Expected: container builds. + +- [ ] **Step 7: Manual end-to-end verification** + +Set up: +1. `docker compose up --build -d minstrel` — pick up new code +2. Generate a token at https://listenbrainz.org/profile/ +3. /settings → paste token → Save +4. /settings → toggle "Send my plays to ListenBrainz" +5. Play a track for ≥240 seconds OR finish it +6. Within 30 seconds, check listenbrainz.org/ — listen should appear +7. /settings → "Last scrobbled" timestamp populates + +Sad path: +8. /settings → enter a deliberately bad token → toggle enabled → play a track +9. Within 30s, check `psql -c "SELECT status, last_error FROM scrobble_queue"` — row marked `failed`, error mentions auth +10. /settings refresh → enabled toggle has been auto-cleared + +- [ ] **Step 8: Update Fable task #345** + +After PR opens, mark `in_progress`. After PR merges, mark `done` with summary of what shipped. + +- [ ] **Step 9: Finishing the branch** + +**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options (merge locally / push + PR / keep / discard), and execute the user's choice. + +Per established cadence: this slice will land as a single-purpose PR. After merge, M4a closes; M4b (similarity ingest) is next. + +--- + +## Self-Review + +**Spec coverage:** +- §3.1 (new packages) → Tasks 3, 4, 5, 6, 7 ✓ +- §3.2 (existing-code hooks) → Task 8 (Writer hooks), Task 9 (api.go Mount), Task 10 (main.go) ✓ +- §4 (database schema) → Tasks 1, 2 ✓ +- §5.1 (LB client) → Task 3 ✓ +- §5.2 (threshold) → Task 4 ✓ +- §5.3 (MaybeEnqueue) → Task 5 ✓ +- §5.4 (Worker, Run, tickOnce, backoffDelay) → Tasks 6, 7 ✓ +- §5.5 (API endpoints) → Task 9 ✓ +- §6 (frontend) → Task 11 ✓ +- §7.1-7.3 (test plan) → embedded across Tasks 3-11 ✓ +- §7.4 (coverage target) → Task 12 step 4 ✓ +- §7.5 (manual verification) → Task 12 step 7 ✓ +- §8 (backwards compat) → preserved by zero-default user columns + new package not affecting existing code ✓ + +No gaps. + +**Placeholder scan:** No "TBD"/"TODO" content. All steps have explicit code or commands. + +**Type consistency:** +- `LBStatus` shape (Go) matches `LBStatus` shape (TypeScript) — `enabled`, `token_set`, `last_scrobbled_at`. +- `MaybeEnqueue(ctx, q, playEventID)` signature consistent across Tasks 5 and 8. +- `backoffDelay(attempts int) (time.Duration, bool)` — same in Task 6 and Task 7. +- `Worker.tickOnce(ctx) error` — same in Task 7 and Task 12. +- sqlc query names consistent across Task 2 (definitions) and Tasks 5/7/9 (callers).