From f9e78602cc7b77b64257cf1baf674076abc0828e Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Tue, 13 Oct 2026 21:48:04 -0500 Subject: [PATCH 1/3] Saved artists: data model and API (MAD-839) An artist saved to your Library is personal and on no service. It's known by its simplified name (match.Simplify), so saving it again from another service finds the same one, and only adds that link to its refs: hints, newest first, for opening its page. - Saving from a room's song (itemId, artistIndex) also remembers the room and who queued it. Autopilot's songs are nobody's. - Pictures come from the music graph's cache (Deezer). An artist the graph doesn't know yet is fetched in the background and filled in on a later list. - GET/POST /me/saved-artists and DELETE /me/saved-artists/{id}. Guests can't save. Migration 00031 adds saved_artists. The web lib has the queries, and artistKey, the same simplification, so the app can tell which artists you already saved. Co-Authored-By: Claude Opus 5.5 --- api/openapi.yaml | 118 +++++++ server/cmd/syncphony/main.go | 4 + server/internal/httpapi/accounts_test.go | 3 +- server/internal/httpapi/api.gen.go | 317 ++++++++++++++++++ server/internal/httpapi/savedartists.go | 79 +++++ server/internal/httpapi/savedartists_test.go | 65 ++++ server/internal/httpapi/server.go | 7 + server/internal/savedartists/savedartists.go | 245 ++++++++++++++ .../savedartists/savedartists_test.go | 85 +++++ .../store/migrations/00031_saved_artists.sql | 28 ++ server/internal/store/models.gen.go | 12 + .../internal/store/queries/saved_artists.sql | 18 + .../internal/store/saved_artists.sql.gen.go | 152 +++++++++ web/src/api/schema.gen.ts | 153 +++++++++ web/src/lib/saved-artists.test.ts | 62 ++++ web/src/lib/saved-artists.ts | 82 +++++ 16 files changed, 1429 insertions(+), 1 deletion(-) create mode 100644 server/internal/httpapi/savedartists.go create mode 100644 server/internal/httpapi/savedartists_test.go create mode 100644 server/internal/savedartists/savedartists.go create mode 100644 server/internal/savedartists/savedartists_test.go create mode 100644 server/internal/store/migrations/00031_saved_artists.sql create mode 100644 server/internal/store/queries/saved_artists.sql create mode 100644 server/internal/store/saved_artists.sql.gen.go create mode 100644 web/src/lib/saved-artists.test.ts create mode 100644 web/src/lib/saved-artists.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index a024976..b182b38 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2756,6 +2756,75 @@ paths: default: $ref: "#/components/responses/Error" + /me/saved-artists: + get: + operationId: listSavedArtists + summary: Artists you saved + description: | + Artists you saved to remember, most recently saved first. They + belong to no service: one is known by its name, and `refs` are the + links it was seen on, as hints for opening its page. + tags: [library] + responses: + "200": + description: Your saved artists + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SavedArtist" + default: + $ref: "#/components/responses/Error" + post: + operationId: saveArtist + summary: Save an artist + description: | + Saves an artist by `name` (with `linkId` and `artistId` if you know + where it's from), or as the `artistIndex`-th artist of a room's song + (`itemId`), which also remembers the room and who queued it. + Saving one you already saved, from anywhere, returns it (200) and + remembers this link too. + tags: [library] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SaveArtistRequest" + responses: + "200": + description: Already saved + content: + application/json: + schema: + $ref: "#/components/schemas/SavedArtist" + "201": + description: Saved + content: + application/json: + schema: + $ref: "#/components/schemas/SavedArtist" + default: + $ref: "#/components/responses/Error" + + /me/saved-artists/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + delete: + operationId: unsaveArtist + summary: Unsave an artist + tags: [library] + responses: + "204": + description: Unsaved + default: + $ref: "#/components/responses/Error" + /playlists: get: operationId: listSavedPlaylists @@ -7174,6 +7243,55 @@ components: artist: $ref: "#/components/schemas/ArtistResult" + SavedArtist: + type: object + required: [id, name, key, refs, savedAt] + properties: + id: + type: string + name: + type: string + key: + type: string + description: | + The simplified name it's known by: another name with the same + key is the same artist. + refs: + type: array + description: Links it was seen on, newest first. A link may since be gone or not yours to use. + items: + $ref: "#/components/schemas/ResolvedRef" + picture: + type: string + description: A Deezer picture, through `/me/discover/images/artist/{picture}`. + roomId: + type: string + description: The room it was heard in, when saved from a room's song. + queuedBy: + type: string + description: Who queued the song it was saved from (user ID). + savedAt: + type: string + format: date-time + + SaveArtistRequest: + type: object + properties: + name: + type: string + maxLength: 200 + linkId: + type: string + artistId: + type: string + itemId: + type: string + description: A room's song; saves its `artistIndex`-th artist instead of `name`. + artistIndex: + type: integer + minimum: 0 + default: 0 + ResolvedRef: type: object description: An artist or album to open with `linkId` and `id`. diff --git a/server/cmd/syncphony/main.go b/server/cmd/syncphony/main.go index be97909..b249622 100644 --- a/server/cmd/syncphony/main.go +++ b/server/cmd/syncphony/main.go @@ -49,6 +49,7 @@ import ( "github.com/madeofpendletonwool/syncphony/server/internal/realtime" "github.com/madeofpendletonwool/syncphony/server/internal/releases" "github.com/madeofpendletonwool/syncphony/server/internal/rooms" + "github.com/madeofpendletonwool/syncphony/server/internal/savedartists" "github.com/madeofpendletonwool/syncphony/server/internal/store" "github.com/madeofpendletonwool/syncphony/server/internal/suggest" "github.com/madeofpendletonwool/syncphony/server/internal/transcode" @@ -373,8 +374,11 @@ func run() error { Bus: a.bus, Presence: presence, BaseURL: cfg.BaseURL, TrustedProxies: cfg.TrustedProxies, } + api.SavedArtists = savedartists.New(db, roomSvc, nil) if len(graph.Sources()) > 0 { api.Graph = graph + // Saved artists' pictures come from the graph's Deezer answers. + api.SavedArtists = savedartists.New(db, roomSvc, graph) } // Personal discovery (ADR 0017): seed sets, minus what's in your library. lib := library.New(db, a.links) diff --git a/server/internal/httpapi/accounts_test.go b/server/internal/httpapi/accounts_test.go index e6ca6e9..239ad19 100644 --- a/server/internal/httpapi/accounts_test.go +++ b/server/internal/httpapi/accounts_test.go @@ -46,6 +46,7 @@ import ( "github.com/madeofpendletonwool/syncphony/server/internal/queue" "github.com/madeofpendletonwool/syncphony/server/internal/realtime" "github.com/madeofpendletonwool/syncphony/server/internal/rooms" + "github.com/madeofpendletonwool/syncphony/server/internal/savedartists" "github.com/madeofpendletonwool/syncphony/server/internal/store" "github.com/madeofpendletonwool/syncphony/server/internal/suggest" "github.com/madeofpendletonwool/syncphony/server/internal/transcode" @@ -147,7 +148,7 @@ func newEnv(t *testing.T) *env { pilot.Now, pilot.Memory = e.clock, &dj.Memory{DB: db, Now: e.clock} t.Cleanup(pilot.Close) api := &httpapi.Server{ - Version: "test", StartedAt: e.clock(), Admin: e.server, Backups: e.backups, Auth: e.svc, Links: e.links, Lyrics: ly, LinerNotes: notes, Artwork: art, Palettes: e.palettes, BeatMaps: beatMaps, Rooms: e.rooms, Queue: qs, Playback: e.playback, Nights: e.nights, Playlists: playlists.New(db, e.rooms, e.links), Games: e.games, Bus: e.bus, Presence: presence, + Version: "test", StartedAt: e.clock(), Admin: e.server, Backups: e.backups, Auth: e.svc, Links: e.links, Lyrics: ly, LinerNotes: notes, Artwork: art, Palettes: e.palettes, BeatMaps: beatMaps, Rooms: e.rooms, Queue: qs, Playback: e.playback, Nights: e.nights, Playlists: playlists.New(db, e.rooms, e.links), SavedArtists: savedartists.New(db, e.rooms, nil), Games: e.games, Bus: e.bus, Presence: presence, Suggest: suggest.New(db, e.rooms, e.links), Autopilot: pilot, Discover: discover.New(db), BaseURL: e.base, PingEvery: 50 * time.Millisecond, } diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go index 4aa4a27..8e78ba6 100644 --- a/server/internal/httpapi/api.gen.go +++ b/server/internal/httpapi/api.gen.go @@ -4354,6 +4354,17 @@ type RoomTaste struct { // in. The owner is always in. type RoomVisibility string +// SaveArtistRequest defines model for SaveArtistRequest. +type SaveArtistRequest struct { + ArtistId *string `json:"artistId,omitempty"` + ArtistIndex *int `json:"artistIndex,omitempty"` + + // ItemId A room's song; saves its `artistIndex`-th artist instead of `name`. + ItemId *string `json:"itemId,omitempty"` + LinkId *string `json:"linkId,omitempty"` + Name *string `json:"name,omitempty"` +} + // SaveNightRequest defines model for SaveNightRequest. type SaveNightRequest struct { From time.Time `json:"from"` @@ -4370,6 +4381,29 @@ type SaveNightRequest struct { To time.Time `json:"to"` } +// SavedArtist defines model for SavedArtist. +type SavedArtist struct { + Id string `json:"id"` + + // Key The simplified name it's known by: another name with the same + // key is the same artist. + Key string `json:"key"` + Name string `json:"name"` + + // Picture A Deezer picture, through `/me/discover/images/artist/{picture}`. + Picture *string `json:"picture,omitempty"` + + // QueuedBy Who queued the song it was saved from (user ID). + QueuedBy *string `json:"queuedBy,omitempty"` + + // Refs Links it was seen on, newest first. A link may since be gone or not yours to use. + Refs []ResolvedRef `json:"refs"` + + // RoomId The room it was heard in, when saved from a room's song. + RoomId *string `json:"roomId,omitempty"` + SavedAt time.Time `json:"savedAt"` +} + // SavedPlaylist A Syncphony playlist and its songs, in order. type SavedPlaylist struct { CreatedAt time.Time `json:"createdAt"` @@ -5186,6 +5220,9 @@ type RenamePasskeyJSONRequestBody RenamePasskeyJSONBody // SetPasswordJSONRequestBody defines body for SetPassword for application/json ContentType. type SetPasswordJSONRequestBody = SetPasswordRequest +// SaveArtistJSONRequestBody defines body for SaveArtist for application/json ContentType. +type SaveArtistJSONRequestBody = SaveArtistRequest + // BeginPairingJSONRequestBody defines body for BeginPairing for application/json ContentType. type BeginPairingJSONRequestBody = BeginPairingRequest @@ -5533,6 +5570,15 @@ type ServerInterface interface { // BeginReauth Start confirming it's you with a passkey // (POST /me/reauth/begin) BeginReauth(w http.ResponseWriter, r *http.Request) + // ListSavedArtists Artists you saved + // (GET /me/saved-artists) + ListSavedArtists(w http.ResponseWriter, r *http.Request) + // SaveArtist Save an artist + // (POST /me/saved-artists) + SaveArtist(w http.ResponseWriter, r *http.Request) + // UnsaveArtist Unsave an artist + // (DELETE /me/saved-artists/{id}) + UnsaveArtist(w http.ResponseWriter, r *http.Request, id string) // RevokeOtherSessions Sign out everywhere else // (DELETE /me/sessions) RevokeOtherSessions(w http.ResponseWriter, r *http.Request) @@ -8037,6 +8083,60 @@ func (siw *ServerInterfaceWrapper) BeginReauth(w http.ResponseWriter, r *http.Re handler.ServeHTTP(w, r) } +// ListSavedArtists operation middleware +func (siw *ServerInterfaceWrapper) ListSavedArtists(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListSavedArtists(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SaveArtist operation middleware +func (siw *ServerInterfaceWrapper) SaveArtist(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SaveArtist(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UnsaveArtist operation middleware +func (siw *ServerInterfaceWrapper) UnsaveArtist(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UnsaveArtist(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // RevokeOtherSessions operation middleware func (siw *ServerInterfaceWrapper) RevokeOtherSessions(w http.ResponseWriter, r *http.Request) { @@ -11439,6 +11539,9 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/rooms/{roomId}/nights", wrapper.EndNight) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/recap", wrapper.GetRoomRecap) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/rooms/{roomId}/playlists", wrapper.SaveNightPlaylist) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/saved-artists", wrapper.ListSavedArtists) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/saved-artists", wrapper.SaveArtist) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/me/saved-artists/{id}", wrapper.UnsaveArtist) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/playlists", wrapper.ListSavedPlaylists) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/playlists", wrapper.CreateSavedPlaylist) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/playlists/{playlistId}", wrapper.DeleteSavedPlaylist) @@ -14852,6 +14955,130 @@ func (response BeginReauthdefaultJSONResponse) VisitBeginReauthResponse(w http.R return err } +type ListSavedArtistsRequestObject struct { +} + +type ListSavedArtistsResponseObject interface { + VisitListSavedArtistsResponse(w http.ResponseWriter) error +} + +type ListSavedArtists200JSONResponse []SavedArtist + +func (response ListSavedArtists200JSONResponse) VisitListSavedArtistsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type ListSavedArtistsdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ListSavedArtistsdefaultJSONResponse) VisitListSavedArtistsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type SaveArtistRequestObject struct { + Body *SaveArtistJSONRequestBody +} + +type SaveArtistResponseObject interface { + VisitSaveArtistResponse(w http.ResponseWriter) error +} + +type SaveArtist200JSONResponse SavedArtist + +func (response SaveArtist200JSONResponse) VisitSaveArtistResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type SaveArtist201JSONResponse SavedArtist + +func (response SaveArtist201JSONResponse) VisitSaveArtistResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + _, err := buf.WriteTo(w) + return err +} + +type SaveArtistdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response SaveArtistdefaultJSONResponse) VisitSaveArtistResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type UnsaveArtistRequestObject struct { + Id string `json:"id"` +} + +type UnsaveArtistResponseObject interface { + VisitUnsaveArtistResponse(w http.ResponseWriter) error +} + +type UnsaveArtist204Response struct { +} + +func (response UnsaveArtist204Response) VisitUnsaveArtistResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type UnsaveArtistdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response UnsaveArtistdefaultJSONResponse) VisitUnsaveArtistResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + type RevokeOtherSessionsRequestObject struct { } @@ -19128,6 +19355,15 @@ type StrictServerInterface interface { // BeginReauth Start confirming it's you with a passkey // (POST /me/reauth/begin) BeginReauth(ctx context.Context, request BeginReauthRequestObject) (BeginReauthResponseObject, error) + // ListSavedArtists Artists you saved + // (GET /me/saved-artists) + ListSavedArtists(ctx context.Context, request ListSavedArtistsRequestObject) (ListSavedArtistsResponseObject, error) + // SaveArtist Save an artist + // (POST /me/saved-artists) + SaveArtist(ctx context.Context, request SaveArtistRequestObject) (SaveArtistResponseObject, error) + // UnsaveArtist Unsave an artist + // (DELETE /me/saved-artists/{id}) + UnsaveArtist(ctx context.Context, request UnsaveArtistRequestObject) (UnsaveArtistResponseObject, error) // RevokeOtherSessions Sign out everywhere else // (DELETE /me/sessions) RevokeOtherSessions(ctx context.Context, request RevokeOtherSessionsRequestObject) (RevokeOtherSessionsResponseObject, error) @@ -21820,6 +22056,87 @@ func (sh *strictHandler) BeginReauth(w http.ResponseWriter, r *http.Request) { } } +// ListSavedArtists operation middleware +func (sh *strictHandler) ListSavedArtists(w http.ResponseWriter, r *http.Request) { + var request ListSavedArtistsRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListSavedArtists(ctx, request.(ListSavedArtistsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListSavedArtists") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListSavedArtistsResponseObject); ok { + if err := validResponse.VisitListSavedArtistsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// SaveArtist operation middleware +func (sh *strictHandler) SaveArtist(w http.ResponseWriter, r *http.Request) { + var request SaveArtistRequestObject + + var body SaveArtistJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SaveArtist(ctx, request.(SaveArtistRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SaveArtist") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SaveArtistResponseObject); ok { + if err := validResponse.VisitSaveArtistResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UnsaveArtist operation middleware +func (sh *strictHandler) UnsaveArtist(w http.ResponseWriter, r *http.Request, id string) { + var request UnsaveArtistRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UnsaveArtist(ctx, request.(UnsaveArtistRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UnsaveArtist") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UnsaveArtistResponseObject); ok { + if err := validResponse.VisitUnsaveArtistResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // RevokeOtherSessions operation middleware func (sh *strictHandler) RevokeOtherSessions(w http.ResponseWriter, r *http.Request) { var request RevokeOtherSessionsRequestObject diff --git a/server/internal/httpapi/savedartists.go b/server/internal/httpapi/savedartists.go new file mode 100644 index 0000000..837ac87 --- /dev/null +++ b/server/internal/httpapi/savedartists.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package httpapi + +import ( + "context" + + "github.com/madeofpendletonwool/syncphony/server/internal/savedartists" +) + +// Saved artists (MAD-839): artists someone wants to remember, in their +// Library beside playlists. Guests can't save: they have no Library. + +// ListSavedArtists returns the caller's saved artists. +func (s *Server) ListSavedArtists(ctx context.Context, _ ListSavedArtistsRequestObject) (ListSavedArtistsResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + as, err := s.SavedArtists.List(ctx, u.ID) + if err != nil { + return nil, err + } + out := make(ListSavedArtists200JSONResponse, len(as)) + for i, a := range as { + out[i] = toSavedArtist(a) + } + return out, nil +} + +// SaveArtist saves an artist, by name or from a room's song. +func (s *Server) SaveArtist(ctx context.Context, req SaveArtistRequestObject) (SaveArtistResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + b := req.Body + in := savedartists.Input{Name: deref(b.Name), LinkID: deref(b.LinkId), ArtistID: deref(b.ArtistId), ItemID: deref(b.ItemId)} + if b.ArtistIndex != nil { + in.ArtistIndex = *b.ArtistIndex + } + a, created, err := s.SavedArtists.Save(ctx, u.ID, in) + if err != nil { + return nil, err + } + if created { + return SaveArtist201JSONResponse(toSavedArtist(a)), nil + } + return SaveArtist200JSONResponse(toSavedArtist(a)), nil +} + +// UnsaveArtist removes one of the caller's saved artists. +func (s *Server) UnsaveArtist(ctx context.Context, req UnsaveArtistRequestObject) (UnsaveArtistResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + if err := s.SavedArtists.Delete(ctx, u.ID, req.Id); err != nil { + return nil, err + } + return UnsaveArtist204Response{}, nil +} + +func toSavedArtist(a savedartists.Artist) SavedArtist { + out := SavedArtist{Id: a.ID, Name: a.Name, Key: a.Key, Refs: make([]ResolvedRef, len(a.Refs)), SavedAt: a.SavedAt} + for i, r := range a.Refs { + out.Refs[i] = ResolvedRef{LinkId: r.LinkID, Id: r.ArtistID} + } + if a.Picture.Valid { + out.Picture = &a.Picture.String + } + if a.RoomID.Valid { + out.RoomId = &a.RoomID.String + } + if a.QueuedBy.Valid { + out.QueuedBy = &a.QueuedBy.String + } + return out +} diff --git a/server/internal/httpapi/savedartists_test.go b/server/internal/httpapi/savedartists_test.go new file mode 100644 index 0000000..b6a5871 --- /dev/null +++ b/server/internal/httpapi/savedartists_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package httpapi_test + +import ( + "net/http" + "testing" + + "github.com/madeofpendletonwool/syncphony/server/internal/httpapi" +) + +func TestSavedArtists(t *testing.T) { + e := newEnv(t) + alice := e.admin() + bob := e.member(alice, "bob") + link := linkFake(t, alice) + room := e.room(t, me(t, alice).Id) + var snap httpapi.QueueSnapshot + alice.want(http.StatusOK, "POST", "/rooms/"+room.ID+"/queue", addReq(link, "t01")).decode(t, &snap) + item := snap.Items[0].Id + + // From the room's song: the room and who queued it come along. + var a httpapi.SavedArtist + alice.want(http.StatusCreated, "POST", "/me/saved-artists", httpapi.SaveArtistRequest{ItemId: &item}).decode(t, &a) + if a.Name != "The Test Patterns" || a.RoomId == nil || *a.RoomId != room.ID || a.QueuedBy == nil || *a.QueuedBy != me(t, alice).Id { + t.Errorf("saved from a song: %+v", a) + } + if len(a.Refs) != 1 || a.Refs[0].LinkId != link || a.Refs[0].Id != "ar1" { + t.Errorf("refs: %+v", a.Refs) + } + + // The same artist by another spelling, from elsewhere, is the same one, + // and remembers the new link first. + var again httpapi.SavedArtist + alice.want(http.StatusOK, "POST", "/me/saved-artists", + httpapi.SaveArtistRequest{Name: ptr("the test-patterns"), LinkId: ptr("other"), ArtistId: ptr("x9")}).decode(t, &again) + if again.Id != a.Id || len(again.Refs) != 2 || again.Refs[0].LinkId != "other" { + t.Errorf("saved again: %+v", again) + } + alice.want(http.StatusCreated, "POST", "/me/saved-artists", httpapi.SaveArtistRequest{Name: ptr("Null Island")}) + + var list []httpapi.SavedArtist + alice.want(http.StatusOK, "GET", "/me/saved-artists", nil).decode(t, &list) + if len(list) != 2 || list[0].Name != "Null Island" { + t.Errorf("list: %+v", list) + } + // They're Alice's alone. + bob.want(http.StatusOK, "GET", "/me/saved-artists", nil).decode(t, &list) + if len(list) != 0 { + t.Errorf("bob sees %+v", list) + } + bob.want(http.StatusNotFound, "DELETE", "/me/saved-artists/"+a.Id, nil) + + alice.want(http.StatusBadRequest, "POST", "/me/saved-artists", httpapi.SaveArtistRequest{Name: ptr(" ")}) + alice.want(http.StatusBadRequest, "POST", "/me/saved-artists", httpapi.SaveArtistRequest{Name: ptr("!!!")}) + alice.want(http.StatusBadRequest, "POST", "/me/saved-artists", httpapi.SaveArtistRequest{ItemId: &item, ArtistIndex: ptr(3)}) + alice.want(http.StatusBadRequest, "POST", "/me/saved-artists", httpapi.SaveArtistRequest{ItemId: ptr("nope")}) + + alice.want(http.StatusNoContent, "DELETE", "/me/saved-artists/"+a.Id, nil) + alice.want(http.StatusNotFound, "DELETE", "/me/saved-artists/"+a.Id, nil) + alice.want(http.StatusOK, "GET", "/me/saved-artists", nil).decode(t, &list) + if len(list) != 1 { + t.Errorf("after unsaving: %+v", list) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index d625b7e..843def2 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -40,6 +40,7 @@ import ( "github.com/madeofpendletonwool/syncphony/server/internal/queue" "github.com/madeofpendletonwool/syncphony/server/internal/realtime" "github.com/madeofpendletonwool/syncphony/server/internal/rooms" + "github.com/madeofpendletonwool/syncphony/server/internal/savedartists" "github.com/madeofpendletonwool/syncphony/server/internal/store" "github.com/madeofpendletonwool/syncphony/server/internal/suggest" "github.com/madeofpendletonwool/syncphony/server/internal/transcode" @@ -80,6 +81,8 @@ type Server struct { Nights *nights.Service // Playlists keeps Syncphony's own playlists. Playlists *playlists.Service + // SavedArtists keeps the artists people saved to their Library. + SavedArtists *savedartists.Service // Discover keeps users' discovery sets and makes their feeds. Discover *discover.Service // Library indexes what users have, for discovery. Optional. @@ -350,7 +353,10 @@ func writeError(w http.ResponseWriter, r *http.Request, err error) { var invalidGame *games.InvalidInputError var invalidPlaylist *playlists.InvalidInputError var invalidDiscover *discover.InvalidInputError + var invalidSaved *savedartists.InvalidInputError switch { + case errors.As(err, &invalidSaved): + writeJSONError(w, http.StatusBadRequest, "invalid_input", invalidSaved.Error()) case errors.As(err, &invalidDiscover): writeJSONError(w, http.StatusBadRequest, "invalid_input", invalidDiscover.Error()) case errors.As(err, &invalidPlaylist): @@ -433,6 +439,7 @@ var errorCodes = []struct { {playlists.ErrForbidden, http.StatusForbidden, "forbidden"}, {playlists.ErrNothingPlayed, http.StatusConflict, "nothing_played"}, {discover.ErrNotFound, http.StatusNotFound, "not_found"}, + {savedartists.ErrNotFound, http.StatusNotFound, "not_found"}, {discover.ErrTooMany, http.StatusConflict, "too_many"}, {games.ErrNoRound, http.StatusConflict, "round_closed"}, {games.ErrRoundRunning, http.StatusConflict, "round_running"}, diff --git a/server/internal/savedartists/savedartists.go b/server/internal/savedartists/savedartists.go new file mode 100644 index 0000000..454544d --- /dev/null +++ b/server/internal/savedartists/savedartists.go @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package savedartists keeps the artists someone saved (MAD-839): "that's +// good, who is it?" while a friend's song plays. A saved artist is personal +// and belongs to no service. It's known by its simplified name, so saving +// it again from another service finds the same one, and the services it +// was seen on are kept only as hints for opening its page. +package savedartists + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "slices" + "strings" + "time" + "unicode/utf8" + + "github.com/madeofpendletonwool/syncphony/server/internal/match" + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/rooms" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// ErrNotFound is a saved artist that isn't the caller's, or doesn't exist. +var ErrNotFound = errors.New("saved artist not found") + +// InvalidInputError is a bad request, such as no name. +type InvalidInputError struct{ Message string } + +func (e *InvalidInputError) Error() string { return e.Message } + +const ( + // MaxName is the longest name, in characters. + MaxName = 200 + // maxRefs is how many services an artist remembers being seen on. + maxRefs = 10 +) + +// Ref is where an artist was seen: its ID on a link. +type Ref struct { + LinkID string `json:"linkId"` + ArtistID string `json:"artistId"` +} + +// Artist is a saved artist. +type Artist struct { + store.SavedArtist + // Refs are the links it was seen on, newest first. + Refs []Ref +} + +// Pictures knows artists' pictures. It's musicgraph.Service in production. +type Pictures interface { + CachedArtist(ctx context.Context, a musicgraph.ArtistRef) (musicgraph.Artist, bool, error) + WarmArtist(as ...musicgraph.ArtistRef) +} + +// Service keeps saved artists. +type Service struct { + db *store.Store + rooms *rooms.Service + // pics may be nil: saved artists then have no pictures. + pics Pictures + // Now is the clock. Default store.Now. + Now func() time.Time +} + +// New returns a Service. pics may be nil. +func New(db *store.Store, rs *rooms.Service, pics Pictures) *Service { + return &Service{db: db, rooms: rs, pics: pics, Now: store.Now} +} + +// Input is an artist to save: by name, with where it was seen (LinkID and +// ArtistID) if known, or as the ArtistIndex-th artist of a room's song +// (ItemID). +type Input struct { + Name string + LinkID, ArtistID string + ItemID string + ArtistIndex int +} + +// Save saves an artist for userID. Saving one already saved adds where it +// was seen this time, and reports created false. +func (s *Service) Save(ctx context.Context, userID string, in Input) (Artist, bool, error) { + var roomID, queuedBy sql.NullString + if in.ItemID != "" { + it, err := s.item(ctx, userID, in.ItemID) + if err != nil { + return Artist{}, false, err + } + var t provider.Track + if err := json.Unmarshal([]byte(it.Metadata), &t); err != nil { + return Artist{}, false, err + } + if in.ArtistIndex < 0 || in.ArtistIndex >= len(t.Artists) { + return Artist{}, false, &InvalidInputError{"that song has no such artist"} + } + a := t.Artists[in.ArtistIndex] + in.Name, in.LinkID, in.ArtistID = a.Name, it.LinkID.String, a.ID + roomID = sql.NullString{String: it.RoomID, Valid: true} + // An autopilot song is nobody's: it's only seeded by AddedBy's taste. + queuedBy = sql.NullString{String: it.AddedBy, Valid: !it.Autopilot.Valid} + } + name := strings.TrimSpace(in.Name) + if name == "" || utf8.RuneCountInString(name) > MaxName { + return Artist{}, false, &InvalidInputError{"name an artist, up to 200 characters"} + } + key := match.Simplify(name) + if key == "" { + return Artist{}, false, &InvalidInputError{"that name has no letters or digits"} + } + var seen []Ref + if in.LinkID != "" && in.ArtistID != "" { + seen = []Ref{{LinkID: in.LinkID, ArtistID: in.ArtistID}} + } + + old, err := s.db.GetSavedArtistByKey(ctx, store.GetSavedArtistByKeyParams{UserID: userID, Key: key}) + if err == nil { + a, err := toArtist(old) + if err != nil { + return Artist{}, false, err + } + if merged := mergeRefs(seen, a.Refs); !slices.Equal(merged, a.Refs) { + if err := s.db.UpdateSavedArtistRefs(ctx, store.UpdateSavedArtistRefsParams{Refs: encode(merged), ID: a.ID}); err != nil { + return Artist{}, false, err + } + a.Refs = merged + a.SavedArtist.Refs = encode(merged) + } + return a, false, nil + } else if !store.IsNotFound(err) { + return Artist{}, false, err + } + + row := store.SavedArtist{ + ID: store.NewID(), UserID: userID, Key: key, Name: name, Refs: encode(mergeRefs(seen, nil)), + Picture: s.picture(ctx, name), RoomID: roomID, QueuedBy: queuedBy, SavedAt: s.Now(), + } + if err := s.db.InsertSavedArtist(ctx, store.InsertSavedArtistParams(row)); err != nil { + return Artist{}, false, err + } + a, err := toArtist(row) + return a, true, err +} + +// List returns userID's saved artists, most recently saved first. Pictures +// the music graph has learned since are filled in. +func (s *Service) List(ctx context.Context, userID string) ([]Artist, error) { + rows, err := s.db.ListSavedArtists(ctx, userID) + if err != nil { + return nil, err + } + out := make([]Artist, len(rows)) + for i, r := range rows { + if !r.Picture.Valid { + if p := s.picture(ctx, r.Name); p.Valid { + if err := s.db.SetSavedArtistPicture(ctx, store.SetSavedArtistPictureParams{Picture: p, ID: r.ID}); err != nil { + return nil, err + } + r.Picture = p + } + } + if out[i], err = toArtist(r); err != nil { + return nil, err + } + } + return out, nil +} + +// Delete unsaves one of userID's artists. +func (s *Service) Delete(ctx context.Context, userID, id string) error { + n, err := s.db.DeleteSavedArtist(ctx, store.DeleteSavedArtistParams{ID: id, UserID: userID}) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} + +// picture is the artist's picture if the music graph has it cached. If +// not, it's fetched in the background, for next time. +func (s *Service) picture(ctx context.Context, name string) sql.NullString { + if s.pics == nil { + return sql.NullString{} + } + ref := musicgraph.ArtistRef{Name: name} + a, ok, err := s.pics.CachedArtist(ctx, ref) + if err != nil || !ok { + s.pics.WarmArtist(ref) + return sql.NullString{} + } + return sql.NullString{String: a.Picture, Valid: a.Picture != ""} +} + +// item returns a room's song, if userID can enter the room. +func (s *Service) item(ctx context.Context, userID, itemID string) (store.QueueItem, error) { + it, err := s.db.GetQueueItem(ctx, itemID) + if store.IsNotFound(err) { + return it, &InvalidInputError{"that song isn't in any room"} + } else if err != nil { + return it, err + } + r, err := s.rooms.Get(ctx, it.RoomID) + if err != nil { + return it, err + } + ok, err := s.rooms.CanEnter(ctx, userID, r) + if err != nil { + return it, err + } + if !ok { + return it, rooms.ErrNotFound + } + return it, nil +} + +// mergeRefs puts seen in front of refs, without repeats, keeping maxRefs. +func mergeRefs(seen, refs []Ref) []Ref { + out := make([]Ref, 0, len(seen)+len(refs)) + for _, r := range slices.Concat(seen, refs) { + if !slices.Contains(out, r) { + out = append(out, r) + } + } + return out[:min(len(out), maxRefs)] +} + +func encode(refs []Ref) string { + b, _ := json.Marshal(refs) //nolint:errchkjson // a slice of plain structs always marshals + return string(b) +} + +func toArtist(r store.SavedArtist) (Artist, error) { + a := Artist{SavedArtist: r} + if err := json.Unmarshal([]byte(r.Refs), &a.Refs); err != nil { + return a, err + } + return a, nil +} diff --git a/server/internal/savedartists/savedartists_test.go b/server/internal/savedartists/savedartists_test.go new file mode 100644 index 0000000..9fa2db3 --- /dev/null +++ b/server/internal/savedartists/savedartists_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package savedartists_test + +import ( + "context" + "path/filepath" + "slices" + "testing" + + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/savedartists" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// pictures is a music graph that knows the pictures in known. +type pictures struct { + known map[string]string + warmed []string +} + +func (p *pictures) CachedArtist(_ context.Context, a musicgraph.ArtistRef) (musicgraph.Artist, bool, error) { + pic, ok := p.known[a.Name] + return musicgraph.Artist{Ref: a, Picture: pic}, ok, nil +} + +func (p *pictures) WarmArtist(as ...musicgraph.ArtistRef) { + for _, a := range as { + p.warmed = append(p.warmed, a.Name) + } +} + +func open(t *testing.T) (*store.Store, string) { + t.Helper() + db, err := store.Open(t.Context(), filepath.Join(t.TempDir(), "syncphony.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + u, err := db.CreateUser(t.Context(), store.CreateUserParams{ID: store.NewID(), Username: "alice", DisplayName: "Alice", Color: "#000", Role: store.RoleMember, CreatedAt: store.Now()}) + if err != nil { + t.Fatal(err) + } + return db, u.ID +} + +func TestPictures(t *testing.T) { + db, alice := open(t) + pics := &pictures{known: map[string]string{"Hum": "abc"}} + s := savedartists.New(db, nil, pics) + + hum, _, err := s.Save(t.Context(), alice, savedartists.Input{Name: "Hum"}) + if err != nil || hum.Picture.String != "abc" { + t.Fatalf("Save(Hum) = %+v, %v", hum, err) + } + // Not known yet: it's fetched in the background, and filled in once it is. + if _, _, err := s.Save(t.Context(), alice, savedartists.Input{Name: "Failure"}); err != nil { + t.Fatal(err) + } + if !slices.Equal(pics.warmed, []string{"Failure"}) { + t.Errorf("warmed %v", pics.warmed) + } + pics.known["Failure"] = "def" + list, err := s.List(t.Context(), alice) + if err != nil || len(list) != 2 || list[0].Name != "Failure" || list[0].Picture.String != "def" { + t.Fatalf("List = %+v, %v", list, err) + } +} + +func TestRefsAreCapped(t *testing.T) { + db, alice := open(t) + s := savedartists.New(db, nil, nil) + var a savedartists.Artist + for i := range 12 { + var err error + a, _, err = s.Save(t.Context(), alice, savedartists.Input{Name: "Hum", LinkID: "l", ArtistID: string(rune('a' + i))}) + if err != nil { + t.Fatal(err) + } + } + // The newest, the twelfth (ID "l"), comes first. + if len(a.Refs) != 10 || a.Refs[0].ArtistID != "l" { + t.Errorf("refs %+v", a.Refs) + } +} diff --git a/server/internal/store/migrations/00031_saved_artists.sql b/server/internal/store/migrations/00031_saved_artists.sql new file mode 100644 index 0000000..779ada3 --- /dev/null +++ b/server/internal/store/migrations/00031_saved_artists.sql @@ -0,0 +1,28 @@ +-- Saved artists (MAD-839): artists someone wants to remember, often one a +-- friend queued. They're personal and belong to no service: an artist is +-- one row however many services it was saved from. + +-- +goose Up +CREATE TABLE saved_artists ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + -- The artist's simplified name (match.Simplify): saving them again, + -- from any service, finds this row. + key TEXT NOT NULL, + name TEXT NOT NULL, + -- Where they've been seen, JSON [{"linkId", "artistId"}], newest + -- first: hints for opening their page. A link may since be gone. + refs TEXT NOT NULL DEFAULT '[]', + -- Their Deezer picture's ID, once the music graph knows it. + picture TEXT, + -- Where they were heard, when saved from a room's song: the room, and + -- who queued it. + room_id TEXT REFERENCES rooms (id) ON DELETE SET NULL, + queued_by TEXT REFERENCES users (id) ON DELETE SET NULL, + saved_at TIMESTAMP NOT NULL, + UNIQUE (user_id, key) +); +CREATE INDEX saved_artists_user ON saved_artists (user_id, saved_at); + +-- +goose Down +DROP TABLE saved_artists; diff --git a/server/internal/store/models.gen.go b/server/internal/store/models.gen.go index 541bfe7..82c0473 100644 --- a/server/internal/store/models.gen.go +++ b/server/internal/store/models.gen.go @@ -366,6 +366,18 @@ type RoomMember struct { CreatedAt time.Time } +type SavedArtist struct { + ID string + UserID string + Key string + Name string + Refs string + Picture sql.NullString + RoomID sql.NullString + QueuedBy sql.NullString + SavedAt time.Time +} + type ServerKey struct { Name string Key []byte diff --git a/server/internal/store/queries/saved_artists.sql b/server/internal/store/queries/saved_artists.sql new file mode 100644 index 0000000..825ac1f --- /dev/null +++ b/server/internal/store/queries/saved_artists.sql @@ -0,0 +1,18 @@ +-- name: ListSavedArtists :many +SELECT * FROM saved_artists WHERE user_id = ? ORDER BY saved_at DESC; + +-- name: GetSavedArtistByKey :one +SELECT * FROM saved_artists WHERE user_id = ? AND key = ?; + +-- name: InsertSavedArtist :exec +INSERT INTO saved_artists (id, user_id, key, name, refs, picture, room_id, queued_by, saved_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: UpdateSavedArtistRefs :exec +UPDATE saved_artists SET refs = ? WHERE id = ?; + +-- name: SetSavedArtistPicture :exec +UPDATE saved_artists SET picture = ? WHERE id = ?; + +-- name: DeleteSavedArtist :execrows +DELETE FROM saved_artists WHERE id = ? AND user_id = ?; diff --git a/server/internal/store/saved_artists.sql.gen.go b/server/internal/store/saved_artists.sql.gen.go new file mode 100644 index 0000000..5631176 --- /dev/null +++ b/server/internal/store/saved_artists.sql.gen.go @@ -0,0 +1,152 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: saved_artists.sql + +package store + +import ( + "context" + "database/sql" + "time" +) + +const deleteSavedArtist = `-- name: DeleteSavedArtist :execrows +DELETE FROM saved_artists WHERE id = ? AND user_id = ? +` + +type DeleteSavedArtistParams struct { + ID string + UserID string +} + +func (q *Queries) DeleteSavedArtist(ctx context.Context, arg DeleteSavedArtistParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteSavedArtist, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getSavedArtistByKey = `-- name: GetSavedArtistByKey :one +SELECT id, user_id, "key", name, refs, picture, room_id, queued_by, saved_at FROM saved_artists WHERE user_id = ? AND key = ? +` + +type GetSavedArtistByKeyParams struct { + UserID string + Key string +} + +func (q *Queries) GetSavedArtistByKey(ctx context.Context, arg GetSavedArtistByKeyParams) (SavedArtist, error) { + row := q.db.QueryRowContext(ctx, getSavedArtistByKey, arg.UserID, arg.Key) + var i SavedArtist + err := row.Scan( + &i.ID, + &i.UserID, + &i.Key, + &i.Name, + &i.Refs, + &i.Picture, + &i.RoomID, + &i.QueuedBy, + &i.SavedAt, + ) + return i, err +} + +const insertSavedArtist = `-- name: InsertSavedArtist :exec +INSERT INTO saved_artists (id, user_id, key, name, refs, picture, room_id, queued_by, saved_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type InsertSavedArtistParams struct { + ID string + UserID string + Key string + Name string + Refs string + Picture sql.NullString + RoomID sql.NullString + QueuedBy sql.NullString + SavedAt time.Time +} + +func (q *Queries) InsertSavedArtist(ctx context.Context, arg InsertSavedArtistParams) error { + _, err := q.db.ExecContext(ctx, insertSavedArtist, + arg.ID, + arg.UserID, + arg.Key, + arg.Name, + arg.Refs, + arg.Picture, + arg.RoomID, + arg.QueuedBy, + arg.SavedAt, + ) + return err +} + +const listSavedArtists = `-- name: ListSavedArtists :many +SELECT id, user_id, "key", name, refs, picture, room_id, queued_by, saved_at FROM saved_artists WHERE user_id = ? ORDER BY saved_at DESC +` + +func (q *Queries) ListSavedArtists(ctx context.Context, userID string) ([]SavedArtist, error) { + rows, err := q.db.QueryContext(ctx, listSavedArtists, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SavedArtist{} + for rows.Next() { + var i SavedArtist + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Key, + &i.Name, + &i.Refs, + &i.Picture, + &i.RoomID, + &i.QueuedBy, + &i.SavedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setSavedArtistPicture = `-- name: SetSavedArtistPicture :exec +UPDATE saved_artists SET picture = ? WHERE id = ? +` + +type SetSavedArtistPictureParams struct { + Picture sql.NullString + ID string +} + +func (q *Queries) SetSavedArtistPicture(ctx context.Context, arg SetSavedArtistPictureParams) error { + _, err := q.db.ExecContext(ctx, setSavedArtistPicture, arg.Picture, arg.ID) + return err +} + +const updateSavedArtistRefs = `-- name: UpdateSavedArtistRefs :exec +UPDATE saved_artists SET refs = ? WHERE id = ? +` + +type UpdateSavedArtistRefsParams struct { + Refs string + ID string +} + +func (q *Queries) UpdateSavedArtistRefs(ctx context.Context, arg UpdateSavedArtistRefsParams) error { + _, err := q.db.ExecContext(ctx, updateSavedArtistRefs, arg.Refs, arg.ID) + return err +} diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index a02b07b..db66ef7 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -2117,6 +2117,55 @@ export interface paths { patch?: never; trace?: never; }; + "/me/saved-artists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Artists you saved + * @description Artists you saved to remember, most recently saved first. They + * belong to no service: one is known by its name, and `refs` are the + * links it was seen on, as hints for opening its page. + */ + get: operations["listSavedArtists"]; + put?: never; + /** + * Save an artist + * @description Saves an artist by `name` (with `linkId` and `artistId` if you know + * where it's from), or as the `artistIndex`-th artist of a room's song + * (`itemId`), which also remembers the room and who queued it. + * Saving one you already saved, from anywhere, returns it (200) and + * remembers this link too. + */ + post: operations["saveArtist"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/saved-artists/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Unsave an artist */ + delete: operations["unsaveArtist"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/playlists": { parameters: { query?: never; @@ -4877,6 +4926,34 @@ export interface components { accountLabel: string; artist: components["schemas"]["ArtistResult"]; }; + SavedArtist: { + id: string; + name: string; + /** + * @description The simplified name it's known by: another name with the same + * key is the same artist. + */ + key: string; + /** @description Links it was seen on, newest first. A link may since be gone or not yours to use. */ + refs: components["schemas"]["ResolvedRef"][]; + /** @description A Deezer picture, through `/me/discover/images/artist/{picture}`. */ + picture?: string; + /** @description The room it was heard in, when saved from a room's song. */ + roomId?: string; + /** @description Who queued the song it was saved from (user ID). */ + queuedBy?: string; + /** Format: date-time */ + savedAt: string; + }; + SaveArtistRequest: { + name?: string; + linkId?: string; + artistId?: string; + /** @description A room's song; saves its `artistIndex`-th artist instead of `name`. */ + itemId?: string; + /** @default 0 */ + artistIndex: number; + }; /** @description An artist or album to open with `linkId` and `id`. */ ResolvedRef: { linkId: string; @@ -8659,6 +8736,82 @@ export interface operations { default: components["responses"]["Error"]; }; }; + listSavedArtists: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Your saved artists */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SavedArtist"][]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + saveArtist: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SaveArtistRequest"]; + }; + }; + responses: { + /** @description Already saved */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SavedArtist"]; + }; + }; + /** @description Saved */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SavedArtist"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + unsaveArtist: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Unsaved */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Error"]; + }; + }; listSavedPlaylists: { parameters: { query?: never; diff --git a/web/src/lib/saved-artists.test.ts b/web/src/lib/saved-artists.test.ts new file mode 100644 index 0000000..2b26008 --- /dev/null +++ b/web/src/lib/saved-artists.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { arrangeSaved, artistKey, findSaved, nowPlayingArtists, openableRef, type SavedArtist } from './saved-artists' + +describe('artistKey', () => { + // The same cases as the server's TestSimplify: the keys must agree. + it.each([ + ["Don't Stop Me Now", 'dont stop me now'], + ['Simon & Garfunkel', 'simon and garfunkel'], + [' AC/DC ', 'ac dc'], + ['Sigur Rós', 'sigur ros'], + ['Beyoncé – Halo!', 'beyonce halo'], + ])('%s → %s', (name, key) => { + expect(artistKey(name)).toBe(key) + }) +}) + +describe('findSaved', () => { + const saved = [{ id: 's1', name: 'Sigur Rós', key: 'sigur ros', refs: [], savedAt: '' }] as SavedArtist[] + it('finds an artist however it’s spelled', () => { + expect(findSaved(saved, 'sigur ros')?.id).toBe('s1') + expect(findSaved(saved, 'Hum')).toBeUndefined() + expect(findSaved(undefined, 'Sigur Rós')).toBeUndefined() + }) +}) + +describe('nowPlayingArtists', () => { + const track = { artists: ['A', 'B'], artistIds: ['a'], linkId: 'l1' } + it('saves from the room’s song when there is one', () => { + expect(nowPlayingArtists({ itemId: 'i1', track } as never)).toEqual([ + { name: 'A', itemId: 'i1', artistIndex: 0 }, + { name: 'B', itemId: 'i1', artistIndex: 1 }, + ]) + }) + it('otherwise by name and where it’s from', () => { + expect(nowPlayingArtists({ track } as never)).toEqual([ + { name: 'A', linkId: 'l1', artistId: 'a' }, + { name: 'B', linkId: 'l1', artistId: undefined }, + ]) + }) +}) + +describe('openableRef', () => { + it('opens through the newest link you can use', () => { + const a = { refs: [{ linkId: 'theirs', id: 'x' }, { linkId: 'mine', id: 'ar1' }, { linkId: 'shared', id: 'ar2' }] } + expect(openableRef(a, [{ id: 'mine' }, { id: 'shared' }])).toEqual({ linkId: 'mine', id: 'ar1' }) + expect(openableRef(a, [])).toBeUndefined() + expect(openableRef({ refs: [] }, [{ id: 'mine' }])).toBeUndefined() + }) +}) + +describe('arrangeSaved', () => { + const at = (name: string, key: string) => ({ id: key, name, key, refs: [], savedAt: '' }) as SavedArtist + // Most recently saved first, as the server lists them. + const saved = [at('Sigur Rós', 'sigur ros'), at('Hum', 'hum'), at('Arab Strap', 'arab strap')] + it('keeps the newest first, or sorts by name', () => { + expect(arrangeSaved(saved, 'recent').map((a) => a.name)).toEqual(['Sigur Rós', 'Hum', 'Arab Strap']) + expect(arrangeSaved(saved, 'name').map((a) => a.name)).toEqual(['Arab Strap', 'Hum', 'Sigur Rós']) + }) + it('filters by name, ignoring accents and case', () => { + expect(arrangeSaved(saved, 'recent', 'RÓS').map((a) => a.name)).toEqual(['Sigur Rós']) + }) +}) diff --git a/web/src/lib/saved-artists.ts b/web/src/lib/saved-artists.ts new file mode 100644 index 0000000..e727604 --- /dev/null +++ b/web/src/lib/saved-artists.ts @@ -0,0 +1,82 @@ +import { queryOptions, type QueryClient } from '@tanstack/react-query' +import { api } from '@/api/client' +import { unwrap } from '@/api/errors' +import type { components } from '@/api/schema.gen' +import type { NowPlaying } from '@/lib/now-playing' + +// Saved artists (MAD-839..841): artists you want to remember, in your +// Library beside playlists. Personal, and on no particular service. + +export type SavedArtist = components['schemas']['SavedArtist'] +/** An artist to save: by name (and where it's from), or one of a room's song's artists. */ +export type ArtistToSave = { name: string; linkId?: string; artistId?: string; itemId?: string; artistIndex?: number } + +export const savedArtistsQuery = queryOptions({ + queryKey: ['saved-artists'], + queryFn: () => unwrap(api.GET('/me/saved-artists')), + staleTime: 60_000, +}) + +export function saveArtist(a: ArtistToSave) { + return unwrap(api.POST('/me/saved-artists', { body: a.itemId ? { itemId: a.itemId, artistIndex: a.artistIndex ?? 0 } : { name: a.name, linkId: a.linkId, artistId: a.artistId, artistIndex: 0 } })) +} + +export function unsaveArtist(id: string) { + return unwrap(api.DELETE('/me/saved-artists/{id}', { params: { path: { id } } })) +} + +/** Puts a just-saved artist at the top of the cached list, then refetches it. */ +export function artistSaved(qc: QueryClient, a: SavedArtist) { + qc.setQueryData(savedArtistsQuery.queryKey, (old) => (old ? [a, ...old.filter((x) => x.id !== a.id)] : old)) + return qc.invalidateQueries({ queryKey: savedArtistsQuery.queryKey }) +} + +export function artistUnsaved(qc: QueryClient, id: string) { + qc.setQueryData(savedArtistsQuery.queryKey, (old) => old?.filter((x) => x.id !== id)) + return qc.invalidateQueries({ queryKey: savedArtistsQuery.queryKey }) +} + +/** + * The name an artist is known by, as the server's match.Simplify makes it: + * lowercase, no accents or punctuation, "&" as "and". Two names with the + * same key are the same saved artist. + */ +export function artistKey(name: string) { + return name + .toLowerCase() + .normalize('NFD') + .replace(/\p{Mn}/gu, '') + .replace(/['’]/g, '') + .replace(/&/g, ' and ') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() +} + +/** The saved artist with name, if it's saved. */ +export function findSaved(saved: SavedArtist[] | undefined, name: string) { + const key = artistKey(name) + return saved?.find((a) => a.key === key) +} + +/** The playing song's artists, to save from the room (which remembers where you heard them). */ +export function nowPlayingArtists(np: Pick): ArtistToSave[] { + return np.track.artists.map((name, i) => + np.itemId + ? { name, itemId: np.itemId, artistIndex: i } + : { name, linkId: np.track.linkId, artistId: np.track.artistIds?.[i] || undefined }, + ) +} + +/** Where to open a saved artist: the newest link it was seen on that you can still use. */ +export function openableRef(a: Pick, usable: { id: string }[] | undefined) { + return a.refs.find((r) => usable?.some((l) => l.id === r.linkId)) +} + +export type ArtistSort = 'recent' | 'name' + +/** Saved artists matching filter (by name, loosely), in sort's order. */ +export function arrangeSaved(saved: SavedArtist[], sort: ArtistSort, filter = '') { + const want = artistKey(filter) + const kept = want ? saved.filter((a) => a.key.includes(want)) : saved + return sort === 'name' ? [...kept].sort((a, b) => a.key.localeCompare(b.key)) : kept +} From d2b11c6aa0a4f6a5e8b88df085a4f067e08680c1 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Tue, 13 Oct 2026 21:48:04 -0500 Subject: [PATCH 2/3] Save an artist from now playing, the room and artist pages (MAD-840) A star saves the artist to your Library. It sits in the now-playing sheet and on the room page beside the playlist bookmark, saving from the room's song so the Library knows where you heard it. Artist pages get a labelled Save artist button. With several artists, the star opens a list to pick from. Saving and removing toast with an Undo, which works even after the star is gone. Co-Authored-By: Claude Opus 5.5 --- .../components/library/save-artist.test.tsx | 59 +++++++ web/src/components/library/save-artist.tsx | 164 ++++++++++++++++++ .../components/shell/now-playing-sheet.tsx | 6 + .../_app/_authed/artist.$linkId.$artistId.tsx | 2 + web/src/routes/_app/_authed/room.tsx | 3 + 5 files changed, 234 insertions(+) create mode 100644 web/src/components/library/save-artist.test.tsx create mode 100644 web/src/components/library/save-artist.tsx diff --git a/web/src/components/library/save-artist.test.tsx b/web/src/components/library/save-artist.test.tsx new file mode 100644 index 0000000..ddf5368 --- /dev/null +++ b/web/src/components/library/save-artist.test.tsx @@ -0,0 +1,59 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { savedArtistsQuery, type SavedArtist } from '@/lib/saved-artists' +import { toasts } from '@/lib/toast' +import { SaveArtistButton } from './save-artist' + +const api = vi.hoisted(() => ({ GET: vi.fn(), POST: vi.fn(), DELETE: vi.fn() })) +vi.mock('@/api/client', () => ({ api })) + +const ok = (data: unknown, status = 200) => Promise.resolve({ data, response: new Response(null, { status }) }) +const hum: SavedArtist = { id: 's1', name: 'Hum', key: 'hum', refs: [], savedAt: '2026-10-13T00:00:00Z' } + +function renderButton(artists: Parameters[0]['artists'], saved: SavedArtist[] = []) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }) + client.setQueryData(savedArtistsQuery.queryKey, saved) + render( + + + , + ) + return client +} + +beforeEach(() => { + for (const f of Object.values(api)) f.mockReset() + api.GET.mockReturnValue(new Promise(() => {})) + toasts.set([]) +}) +afterEach(cleanup) + +describe('SaveArtistButton', () => { + it('saves a room song’s artist, and offers to undo', async () => { + api.POST.mockReturnValue(ok(hum, 201)) + api.DELETE.mockReturnValue(ok(undefined, 204)) + const client = renderButton([{ name: 'Hum', itemId: 'i1', artistIndex: 0 }]) + fireEvent.click(screen.getByRole('button', { name: 'Save Hum to your Library' })) + await waitFor(() => expect(client.getQueryData(savedArtistsQuery.queryKey)).toEqual([hum])) + expect(api.POST).toHaveBeenCalledWith('/me/saved-artists', { body: { itemId: 'i1', artistIndex: 0 } }) + expect(screen.getByRole('button', { name: 'Remove Hum from your Library' }).getAttribute('aria-pressed')).toBe('true') + + const [t] = toasts.get() + expect(t.message).toBe('Saved Hum to your Library') + t.action!.onClick() + await waitFor(() => expect(api.DELETE).toHaveBeenCalledWith('/me/saved-artists/{id}', { params: { path: { id: 's1' } } })) + }) + + it('knows an artist saved under another spelling', () => { + renderButton([{ name: 'HUM' }], [hum]) + expect(screen.getByRole('button', { name: 'Remove HUM from your Library' })).toBeTruthy() + }) + + it('lets you pick from a song’s artists', async () => { + renderButton([{ name: 'Hum' }, { name: 'Failure' }], [hum]) + fireEvent.click(screen.getByRole('button', { name: 'Save one of the artists to your Library' })) + expect(await screen.findByRole('button', { name: 'Save Failure to your Library' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Remove Hum from your Library' })).toBeTruthy() + }) +}) diff --git a/web/src/components/library/save-artist.tsx b/web/src/components/library/save-artist.tsx new file mode 100644 index 0000000..05aa2ae --- /dev/null +++ b/web/src/components/library/save-artist.tsx @@ -0,0 +1,164 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { LoaderCircle, Star } from 'lucide-react' +import { useState } from 'react' +import { errorMessage } from '@/api/errors' +import { Sheet } from '@/components/sheet' +import { Button } from '@/components/ui/button' +import { tap } from '@/lib/haptics' +import { + artistSaved, + artistUnsaved, + findSaved, + saveArtist, + savedArtistsQuery, + unsaveArtist, + type ArtistToSave, + type SavedArtist, +} from '@/lib/saved-artists' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' + +// Saving an artist (MAD-840): a star, so it doesn't read as the bookmark +// that saves a song to a playlist. + +/** Saves or unsaves one artist, with an Undo on the toast. */ +function useToggleArtist(artist: ArtistToSave) { + const qc = useQueryClient() + const saved = useQuery(savedArtistsQuery) + const current = findSaved(saved.data, artist.name) + + const fail = (e: unknown) => toast({ message: errorMessage(e), tone: 'error' }) + // Undo can be tapped after this button is gone (now playing closed, say), + // so it calls the server itself rather than through the mutations. + const undoSave = (a: SavedArtist) => () => + void unsaveArtist(a.id).then(() => artistUnsaved(qc, a.id), fail) + const undoUnsave = () => void saveArtist(artist).then((a) => artistSaved(qc, a), fail) + + const save = useMutation({ + mutationFn: () => saveArtist(artist), + onSuccess: (a) => { + void artistSaved(qc, a) + toast({ message: `Saved ${a.name} to your Library`, action: { label: 'Undo', onClick: undoSave(a) } }) + }, + onError: fail, + }) + const unsave = useMutation({ + mutationFn: (a: SavedArtist) => unsaveArtist(a.id), + onSuccess: (_, a) => { + void artistUnsaved(qc, a.id) + toast({ message: `Removed ${a.name} from your Library`, action: { label: 'Undo', onClick: undoUnsave } }) + }, + onError: fail, + }) + + return { + saved: !!current, + busy: save.isPending || unsave.isPending, + toggle: () => { + tap() + if (current) unsave.mutate(current) + else save.mutate() + }, + } +} + +/** + * A star that saves an artist to your Library. A song with several artists + * opens a list to pick from. `variant` "button" is the labelled one for + * artist pages. + */ +export function SaveArtistButton({ + artists, + variant = 'icon', + className, +}: { + artists: ArtistToSave[] + variant?: 'icon' | 'button' + className?: string +}) { + const [picking, setPicking] = useState(false) + if (artists.length === 0) return null + if (artists.length === 1) return + return ( + <> + setPicking(true)} className={className} /> + +
    + {artists.map((a) => ( + + ))} +
+
+ + ) +} + +function ToggleOne({ artist, variant, className }: { artist: ArtistToSave; variant: 'icon' | 'button'; className?: string }) { + const t = useToggleArtist(artist) + const label = t.saved ? `Remove ${artist.name} from your Library` : `Save ${artist.name} to your Library` + if (variant === 'button') { + return ( + + ) + } + return +} + +function PickerButton({ artists, onClick, className }: { artists: ArtistToSave[]; onClick: () => void; className?: string }) { + const saved = useQuery(savedArtistsQuery) + const any = artists.some((a) => findSaved(saved.data, a.name)) + return +} + +function StarButton({ + label, + saved, + busy, + onClick, + className, +}: { + label: string + saved: boolean + busy?: boolean + onClick: () => void + className?: string +}) { + return ( + + ) +} + +function PickRow({ artist }: { artist: ArtistToSave }) { + const t = useToggleArtist(artist) + return ( +
  • + {artist.name} + +
  • + ) +} diff --git a/web/src/components/shell/now-playing-sheet.tsx b/web/src/components/shell/now-playing-sheet.tsx index f46f94c..01ee910 100644 --- a/web/src/components/shell/now-playing-sheet.tsx +++ b/web/src/components/shell/now-playing-sheet.tsx @@ -6,6 +6,7 @@ import { Artwork } from '@/components/artwork' import { LyricsView } from '@/components/lyrics/lyrics-view' import { AutopilotBadge, AutopilotWhy } from '@/components/room/autopilot-badge' import { SourceTag } from '@/components/service-tag' +import { SaveArtistButton } from '@/components/library/save-artist' import { AlbumLink, ArtistLinks } from '@/components/linked-names' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -13,10 +14,13 @@ import { Slider } from '@/components/ui/slider' import { UserAvatar } from '@/components/user-avatar' import { useBeat } from '@/hooks/use-beat' import { usePosition } from '@/hooks/use-position' +import { useMe } from '@/lib/auth' +import { isGuest } from '@/lib/guests' import { laneStyle } from '@/lib/lane' import { lightsOpen, visualizerOpen } from '@/lib/beat' import { easeOutExpo, spring } from '@/lib/motion' import { formatDuration, usePlayer } from '@/lib/now-playing' +import { nowPlayingArtists } from '@/lib/saved-artists' import { AlbumBackdrop } from './album-backdrop' import { TransportControls } from './player-controls' import { BeatLab } from './beat-lab' @@ -40,6 +44,7 @@ export function NowPlayingSheet({ onLyricsChange: (toggle: (lyrics: boolean) => boolean) => void }) { const { nowPlaying: np, commands } = usePlayer() + const me = useMe() const position = usePosition(np) const drag = useDragControls() // While scrubbing, show the thumb where the finger is, not the live position. @@ -181,6 +186,7 @@ export function NowPlayingSheet({ )} {np.autopilot && } + {!isGuest(me) && } {np.autopilot && } diff --git a/web/src/routes/_app/_authed/artist.$linkId.$artistId.tsx b/web/src/routes/_app/_authed/artist.$linkId.$artistId.tsx index 6cf170b..3369620 100644 --- a/web/src/routes/_app/_authed/artist.$linkId.$artistId.tsx +++ b/web/src/routes/_app/_authed/artist.$linkId.$artistId.tsx @@ -18,6 +18,7 @@ import { import { Artwork } from '@/components/artwork' import { BackButton } from '@/components/back-button' import { AddToSetButton } from '@/components/discover/add-to-set' +import { SaveArtistButton } from '@/components/library/save-artist' import { Notice } from '@/components/notice' import { ProviderIcon } from '@/components/provider-icon' import { Button } from '@/components/ui/button' @@ -141,6 +142,7 @@ function Artist() { Shuffle + {!me.guest && } {!me.guest && } {elsewhere.data && elsewhere.data.length > 0 && ( diff --git a/web/src/routes/_app/_authed/room.tsx b/web/src/routes/_app/_authed/room.tsx index ff9d7b9..2358185 100644 --- a/web/src/routes/_app/_authed/room.tsx +++ b/web/src/routes/_app/_authed/room.tsx @@ -6,6 +6,7 @@ import { DropdownMenu } from 'radix-ui' import { useEffect, useState, type ReactNode } from 'react' import { errorMessage } from '@/api/errors' import { Artwork } from '@/components/artwork' +import { SaveArtistButton } from '@/components/library/save-artist' import { PageHeader } from '@/components/page-header' import { SaveToPlaylistButton } from '@/components/playlists/save-to-playlist' import { AutopilotBadge } from '@/components/room/autopilot-badge' @@ -46,6 +47,7 @@ import { can, playbackQuery, songsBeforeYours, type QueueItem } from '@/lib/play import { chooseRoom, leaveRoom, queueQuery, useCurrentRoom, type Room as RoomInfo } from '@/lib/room' import { live } from '@/lib/room-socket' import { useStore } from '@/lib/store' +import { nowPlayingArtists } from '@/lib/saved-artists' import { relativeTime } from '@/lib/time' import { toast } from '@/lib/toast' import { usersQuery } from '@/lib/users' @@ -430,6 +432,7 @@ function NowPlayingCard({ room, waiting }: { room: RoomInfo; waiting: number }) {np.autopilot && } + {!isGuest(me) && } {np.itemId && !isGuest(me) && } From 5087f137bdda4cdb3e08c1f643df82894689b208 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Tue, 13 Oct 2026 21:48:04 -0500 Subject: [PATCH 3/3] Library: an Artists section beside playlists (MAD-841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Library switches between Playlists and Artists (?show=artists). Saved artists are listed newest first or A–Z, with a filter once there are many. Each shows its picture, where it was heard and who queued it. Tapping one opens the artist on the newest of its links you can use, or finds it by name. Each can be added to a discovery set or unsaved. ArtistLink takes children, so a whole row can be the link. Co-Authored-By: Claude Opus 5.5 --- .../components/library/saved-artists.test.tsx | 47 +++++++ web/src/components/library/saved-artists.tsx | 131 ++++++++++++++++++ web/src/components/linked-names.tsx | 18 ++- web/src/routes/_app/_authed/library.index.tsx | 58 ++++++-- 4 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 web/src/components/library/saved-artists.test.tsx create mode 100644 web/src/components/library/saved-artists.tsx diff --git a/web/src/components/library/saved-artists.test.tsx b/web/src/components/library/saved-artists.test.tsx new file mode 100644 index 0000000..d688206 --- /dev/null +++ b/web/src/components/library/saved-artists.test.tsx @@ -0,0 +1,47 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { roomsQuery, type Room } from '@/lib/room' +import { savedArtistsQuery, type SavedArtist } from '@/lib/saved-artists' +import { usableLinksQuery } from '@/lib/services' +import { usersQuery } from '@/lib/users' +import { InRouter } from '@/test/router' +import { SavedArtists } from './saved-artists' + +vi.mock('@/api/client', () => ({ api: { GET: vi.fn(() => new Promise(() => {})), POST: vi.fn(), DELETE: vi.fn() } })) + +function renderList(saved: SavedArtist[]) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }) + client.setQueryData(savedArtistsQuery.queryKey, saved) + client.setQueryData(usableLinksQuery.queryKey, [{ id: 'mine' }] as never) + client.setQueryData(roomsQuery.queryKey, [{ id: 'r1', name: 'Living room' } as Room]) + client.setQueryData(usersQuery.queryKey, [{ id: 'bob', displayName: 'Bob Smith' }] as never) + render( + + + + + , + ) +} + +afterEach(cleanup) + +describe('SavedArtists', () => { + it('lists where each was heard, opening it on a service you can use', async () => { + renderList([ + { + id: 's1', name: 'Hum', key: 'hum', savedAt: new Date().toISOString(), roomId: 'r1', queuedBy: 'bob', + refs: [{ linkId: 'theirs', id: 'x' }, { linkId: 'mine', id: 'ar7' }], + }, + ]) + const link = await screen.findByRole('link', { name: /Hum/ }) + expect(link.getAttribute('href')).toBe('/artist/mine/ar7') + expect(screen.getByText(/Living room · queued by Bob/)).toBeTruthy() + }) + + it('explains how to save one when there are none', async () => { + renderList([]) + expect(await screen.findByText(/No artists yet/)).toBeTruthy() + }) +}) diff --git a/web/src/components/library/saved-artists.tsx b/web/src/components/library/saved-artists.tsx new file mode 100644 index 0000000..5298645 --- /dev/null +++ b/web/src/components/library/saved-artists.tsx @@ -0,0 +1,131 @@ +import { useQuery } from '@tanstack/react-query' +import { Compass, Search, Star } from 'lucide-react' +import { motion } from 'motion/react' +import { useState } from 'react' +import { AddToSetSheet } from '@/components/discover/add-to-set' +import { SaveArtistButton } from '@/components/library/save-artist' +import { ArtistLink } from '@/components/linked-names' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' +import { imageUrl } from '@/lib/discover' +import { fadeUp, stagger } from '@/lib/motion' +import { roomsQuery } from '@/lib/room' +import { arrangeSaved, openableRef, savedArtistsQuery, type ArtistSort, type SavedArtist } from '@/lib/saved-artists' +import { usableLinksQuery } from '@/lib/services' +import { relativeTime } from '@/lib/time' +import { usersQuery } from '@/lib/users' + +// The Library's Artists (MAD-841): everyone you saved, and where you heard them. + +// With more than this many, a filter box shows. +const FILTER_FROM = 12 + +export function SavedArtists() { + const saved = useQuery(savedArtistsQuery) + const [sort, setSort] = useState('recent') + const [filter, setFilter] = useState('') + + if (saved.isPending) { + return ( +
    + {[0, 1, 2, 3].map((i) => ( + + ))} +
    + ) + } + const all = saved.data ?? [] + if (all.length === 0) { + return ( +
    +

    + No artists yet. When something good comes on, tap the by the artist in now + playing, or save them from their page. +

    +
    + ) + } + const shown = arrangeSaved(all, sort, filter) + return ( +
    +
    + v && setSort(v as ArtistSort)} aria-label="Sort artists"> + Recent + A–Z + + {all.length > FILTER_FROM && ( + + )} +
    + {shown.length === 0 ? ( +

    No saved artist matches “{filter}”.

    + ) : ( + + {shown.map((a) => ( + + ))} + + )} +
    + ) +} + +function SavedArtistRow({ artist: a }: { artist: SavedArtist }) { + const links = useQuery(usableLinksQuery) + const rooms = useQuery(roomsQuery) + const users = useQuery(usersQuery) + const [adding, setAdding] = useState(false) + const ref = openableRef(a, links.data) + const room = a.roomId ? rooms.data?.find((r) => r.id === a.roomId) : undefined + const by = a.queuedBy ? users.data?.find((u) => u.id === a.queuedBy) : undefined + const heard = [room?.name, by && `queued by ${by.displayName.split(' ')[0]}`].filter(Boolean).join(' · ') + + return ( + + + + + {a.name} + + {heard ? `${heard} · ` : 'Saved '} + {relativeTime(a.savedAt)} + + + + + {/* Undo after removing brings it back from where it was last seen. */} + + + + ) +} + +function ArtistPicture({ artist: a }: { artist: SavedArtist }) { + const [failed, setFailed] = useState(false) + const src = imageUrl('artist', a.picture, 120) + if (src && !failed) { + return setFailed(true)} className="size-12 shrink-0 rounded-full object-cover" /> + } + return ( + + {a.name.slice(0, 1).toUpperCase()} + + ) +} diff --git a/web/src/components/linked-names.tsx b/web/src/components/linked-names.tsx index 790c16e..1b2d102 100644 --- a/web/src/components/linked-names.tsx +++ b/web/src/components/linked-names.tsx @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useNavigate } from '@tanstack/react-router' -import { Fragment, useState, type MouseEvent } from 'react' +import { Fragment, useState, type MouseEvent, type ReactNode } from 'react' import type { Track } from '@/lib/now-playing' import { albumTarget, @@ -37,7 +37,7 @@ function LookupLink({ target, className, }: { - children: string + children: ReactNode search: { q: string; tab: 'artists' | 'albums' } target: () => Promise className?: string @@ -66,14 +66,20 @@ function LookupLink({ ) } -/** One artist's name, as a link to them. */ -export function ArtistLink({ name, id, linkId, className }: Credit & { linkId?: string; className?: string }) { +/** One artist's name, as a link to them. `children` replaces the name, for a bigger target like a card. */ +export function ArtistLink({ + name, + id, + linkId, + className, + children, +}: Credit & { linkId?: string; className?: string; children?: ReactNode }) { const qc = useQueryClient() const direct = useDirect(linkId, id) if (direct && id) { return ( - {name} + {children ?? name} ) } @@ -83,7 +89,7 @@ export function ArtistLink({ name, id, linkId, className }: Credit & { linkId?: target={() => artistTarget(name, () => qc.fetchQuery(resolveArtistQuery(name)))} className={className} > - {name} + {children ?? name} ) } diff --git a/web/src/routes/_app/_authed/library.index.tsx b/web/src/routes/_app/_authed/library.index.tsx index 331e51f..f4d2cf3 100644 --- a/web/src/routes/_app/_authed/library.index.tsx +++ b/web/src/routes/_app/_authed/library.index.tsx @@ -1,14 +1,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' -import { Moon, Plus, Users } from 'lucide-react' +import { ListMusic, Moon, Plus, Star, Users } from 'lucide-react' import { motion } from 'motion/react' import { useState } from 'react' import { errorMessage } from '@/api/errors' +import { SavedArtists } from '@/components/library/saved-artists' import { PageHeader } from '@/components/page-header' import { PlaylistCover } from '@/components/playlists/playlist-cover' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { useMe } from '@/lib/auth' import { fadeUp, stagger } from '@/lib/motion' import { createPlaylist, playlistChanged, savedPlaylistsQuery, type SavedPlaylistSummary } from '@/lib/playlists' @@ -16,31 +18,65 @@ import { roomsQuery } from '@/lib/room' import { toast } from '@/lib/toast' import { usersQuery } from '@/lib/users' +type Section = 'playlists' | 'artists' + export const Route = createFileRoute('/_app/_authed/library/')({ + validateSearch: (search: Record): { show?: 'artists' } => (search.show === 'artists' ? { show: 'artists' } : {}), loader: ({ context }) => context.queryClient.prefetchQuery(savedPlaylistsQuery), component: Library, }) -/** Your Syncphony playlists, and the ones friends shared with your rooms. */ +/** Your Syncphony playlists and saved artists. */ function Library() { - const me = useMe() - const lists = useQuery(savedPlaylistsQuery) + const { show } = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + const section: Section = show ?? 'playlists' const [naming, setNaming] = useState(false) - const all = lists.data ?? [] - const mine = all.filter((p) => p.ownerId === me.id) - const shared = all.filter((p) => p.ownerId !== me.id) return ( <> setNaming((n) => !n)}> - - + section === 'playlists' && ( + + ) } /> + v && void navigate({ search: v === 'artists' ? { show: 'artists' } : {}, replace: true })} + aria-label="Library section" + className="mb-5" + > + + + Playlists + + + + Artists + + + {section === 'artists' ? : } + + ) +} + +/** Your Syncphony playlists, and the ones friends shared with your rooms. */ +function Playlists({ naming, setNaming }: { naming: boolean; setNaming: (naming: boolean) => void }) { + const me = useMe() + const lists = useQuery(savedPlaylistsQuery) + const all = lists.data ?? [] + const mine = all.filter((p) => p.ownerId === me.id) + const shared = all.filter((p) => p.ownerId !== me.id) + + return ( + <> {naming && setNaming(false)} />} {lists.isPending ? (