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/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/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/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/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 +} 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/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 ? (
    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) && }