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.Use(auth.RequireUser(pool))
|
||||||
authed.Post("/auth/logout", h.handleLogout)
|
authed.Post("/auth/logout", h.handleLogout)
|
||||||
authed.Get("/me", h.handleGetMe)
|
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", h.handleListArtists)
|
||||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
func truncateLibrary(t *testing.T, pool *pgxpool.Pool) {
|
func truncateLibrary(t *testing.T, pool *pgxpool.Pool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := pool.Exec(context.Background(),
|
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)
|
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
|
SELECT
|
||||||
u.listenbrainz_token,
|
u.listenbrainz_token,
|
||||||
u.listenbrainz_enabled,
|
u.listenbrainz_enabled,
|
||||||
(SELECT MAX(pe.scrobbled_at)
|
(SELECT MAX(pe.scrobbled_at)::timestamptz
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
WHERE pe.user_id = u.id) AS last_scrobbled_at
|
WHERE pe.user_id = u.id) AS last_scrobbled_at
|
||||||
FROM users u
|
FROM users u
|
||||||
@@ -71,7 +71,7 @@ WHERE u.id = $1
|
|||||||
type GetListenBrainzConfigRow struct {
|
type GetListenBrainzConfigRow struct {
|
||||||
ListenbrainzToken *string
|
ListenbrainzToken *string
|
||||||
ListenbrainzEnabled bool
|
ListenbrainzEnabled bool
|
||||||
LastScrobbledAt interface{}
|
LastScrobbledAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the user's LB token + enabled flag and the most recent
|
// Returns the user's LB token + enabled flag and the most recent
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ WHERE id = $1;
|
|||||||
SELECT
|
SELECT
|
||||||
u.listenbrainz_token,
|
u.listenbrainz_token,
|
||||||
u.listenbrainz_enabled,
|
u.listenbrainz_enabled,
|
||||||
(SELECT MAX(pe.scrobbled_at)
|
(SELECT MAX(pe.scrobbled_at)::timestamptz
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
WHERE pe.user_id = u.id) AS last_scrobbled_at
|
WHERE pe.user_id = u.id) AS last_scrobbled_at
|
||||||
FROM users u
|
FROM users u
|
||||||
|
|||||||
Reference in New Issue
Block a user