feat(api): add GET/PUT /api/me/listenbrainz endpoints

Exposes user ListenBrainz config (token_set bool, enabled, last_scrobbled_at)
via write-only token semantics; enforces no-enable-without-token at the API layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 09:27:06 -04:00
parent 4d123f1b71
commit 1d1119ac32
6 changed files with 233 additions and 4 deletions
+2
View File
@@ -29,6 +29,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Use(auth.RequireUser(pool))
authed.Post("/auth/logout", h.handleLogout)
authed.Get("/me", h.handleGetMe)
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
authed.Get("/artists", h.handleListArtists)
authed.Get("/artists/{id}", h.handleGetArtist)
+1 -1
View File
@@ -19,7 +19,7 @@ import (
func truncateLibrary(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
if _, err := pool.Exec(context.Background(),
"TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
"TRUNCATE tracks, albums, artists, scrobble_queue RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("truncate: %v", err)
}
}
+124
View File
@@ -0,0 +1,124 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// LBStatus is the GET response body and the PUT response body. Token is
// write-only — `token_set` is the only signal back to the client about
// whether a token exists.
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 := h.buildLBStatus(r.Context(), 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
}
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. Re-read state since the
// preceding token update may have just set/cleared it.
cfg, err := q.GetListenBrainzConfig(r.Context(), user.ID)
if err != nil {
h.logger.Error("api: get listenbrainz config (pre-enable)", "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 := h.buildLBStatus(r.Context(), 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 (h *handlers) buildLBStatus(ctx context.Context, userID pgtype.UUID) (LBStatus, error) {
q := dbq.New(h.pool)
cfg, err := q.GetListenBrainzConfig(ctx, 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
}
+103
View File
@@ -0,0 +1,103 @@
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(context.WithValue(req.Context(), auth.UserCtxKeyForTest(), 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(context.WithValue(req.Context(), auth.UserCtxKeyForTest(), 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 body = %s", w.Code, w.Body.String())
}
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 body = %s", clear.Code, clear.Body.String())
}
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)
}
}
+2 -2
View File
@@ -61,7 +61,7 @@ const getListenBrainzConfig = `-- name: GetListenBrainzConfig :one
SELECT
u.listenbrainz_token,
u.listenbrainz_enabled,
(SELECT MAX(pe.scrobbled_at)
(SELECT MAX(pe.scrobbled_at)::timestamptz
FROM play_events pe
WHERE pe.user_id = u.id) AS last_scrobbled_at
FROM users u
@@ -71,7 +71,7 @@ WHERE u.id = $1
type GetListenBrainzConfigRow struct {
ListenbrainzToken *string
ListenbrainzEnabled bool
LastScrobbledAt interface{}
LastScrobbledAt pgtype.Timestamptz
}
// Returns the user's LB token + enabled flag and the most recent
+1 -1
View File
@@ -37,7 +37,7 @@ WHERE id = $1;
SELECT
u.listenbrainz_token,
u.listenbrainz_enabled,
(SELECT MAX(pe.scrobbled_at)
(SELECT MAX(pe.scrobbled_at)::timestamptz
FROM play_events pe
WHERE pe.user_id = u.id) AS last_scrobbled_at
FROM users u