diff --git a/internal/api/api.go b/internal/api/api.go index 0ab003c5..31463b2a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/api/library_fixtures_test.go b/internal/api/library_fixtures_test.go index 6c6a7d2f..2a8c6f16 100644 --- a/internal/api/library_fixtures_test.go +++ b/internal/api/library_fixtures_test.go @@ -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) } } diff --git a/internal/api/listenbrainz.go b/internal/api/listenbrainz.go new file mode 100644 index 00000000..2276f45c --- /dev/null +++ b/internal/api/listenbrainz.go @@ -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 +} diff --git a/internal/api/listenbrainz_test.go b/internal/api/listenbrainz_test.go new file mode 100644 index 00000000..5029fbb6 --- /dev/null +++ b/internal/api/listenbrainz_test.go @@ -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) + } +} diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index a5bfef95..d15f691e 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -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 diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index f1109a0f..4671015a 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -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