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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user