From d1b508114aef73b5d9389c6613586d5771da5eb7 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Tue, 13 Oct 2026 15:59:02 -0500 Subject: [PATCH 1/2] Linked names: artist and album links that work for everyone (MAD-837) ArtistLink, ArtistLinks and AlbumLink replace track-credits. A name links straight to its page when the link it came from is one you can use. Otherwise, tapping it looks it up by name on your services through the new GET /resolve/artist and /resolve/album, your own links before shared ones. When none has it, you get a search for the name. Guests can use them too. Now playing closes on any page change, so links inside it, or in its queue, don't open the page behind it. Co-Authored-By: Claude Opus 5.5 --- api/openapi.yaml | 69 +++++ server/internal/artistpage/artistpage.go | 23 ++ server/internal/artistpage/artistpage_test.go | 13 + server/internal/httpapi/api.gen.go | 240 ++++++++++++++++++ server/internal/httpapi/artistpage_test.go | 24 ++ server/internal/httpapi/guests.go | 3 + server/internal/httpapi/resolve.go | 96 +++++++ web/src/api/schema.gen.ts | 96 +++++++ web/src/components/linked-names.test.tsx | 62 +++++ web/src/components/linked-names.tsx | 140 ++++++++++ web/src/components/shell/app-shell.tsx | 12 +- .../components/shell/now-playing-sheet.tsx | 11 +- web/src/components/track-credits.tsx | 80 ------ web/src/lib/names.test.ts | 47 ++++ web/src/lib/names.ts | 65 +++++ web/src/routes/_app/_authed/room.tsx | 2 +- web/src/test/router.tsx | 14 + 17 files changed, 907 insertions(+), 90 deletions(-) create mode 100644 server/internal/httpapi/resolve.go create mode 100644 web/src/components/linked-names.test.tsx create mode 100644 web/src/components/linked-names.tsx delete mode 100644 web/src/components/track-credits.tsx create mode 100644 web/src/lib/names.test.ts create mode 100644 web/src/lib/names.ts create mode 100644 web/src/test/router.tsx diff --git a/api/openapi.yaml b/api/openapi.yaml index da8494a..a024976 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1491,6 +1491,65 @@ paths: default: $ref: "#/components/responses/Error" + /resolve/artist: + get: + operationId: resolveArtist + summary: Find an artist on a service you can use + description: | + For a name that came from somewhere you can't browse (a song queued + from someone else's service, or one without an artist ID). Looks + for the artist by name on your links, then on shared ones, and + returns the first exact match. 404 when none has it. + tags: [browse] + parameters: + - name: name + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 200 + responses: + "200": + description: Where to open the artist + content: + application/json: + schema: + $ref: "#/components/schemas/ResolvedRef" + default: + $ref: "#/components/responses/Error" + + /resolve/album: + get: + operationId: resolveAlbum + summary: Find an album on a service you can use + description: | + Like `resolveArtist`, for an album by its title and, when known, its + artist. + tags: [browse] + parameters: + - name: title + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 200 + - name: artist + in: query + schema: + type: string + maxLength: 200 + responses: + "200": + description: Where to open the album + content: + application/json: + schema: + $ref: "#/components/schemas/ResolvedRef" + default: + $ref: "#/components/responses/Error" + /links/{id}/artists/{artistId}/shuffle: parameters: - name: id @@ -7115,6 +7174,16 @@ components: artist: $ref: "#/components/schemas/ArtistResult" + ResolvedRef: + type: object + description: An artist or album to open with `linkId` and `id`. + required: [linkId, id] + properties: + linkId: + type: string + id: + type: string + AlbumAbout: type: object required: [labels, genres] diff --git a/server/internal/artistpage/artistpage.go b/server/internal/artistpage/artistpage.go index c798571..5d801ce 100644 --- a/server/internal/artistpage/artistpage.go +++ b/server/internal/artistpage/artistpage.go @@ -433,6 +433,29 @@ func Elsewhere(ctx context.Context, sess provider.Session, caps provider.Capabil return nil } +// AlbumElsewhere finds an album by title on another link, by the same +// artist when artist isn't empty, or nil. +func AlbumElsewhere(ctx context.Context, sess provider.Session, caps provider.Capabilities, title, artist string) *provider.Album { + if !caps.CanSearch(provider.KindAlbum) { + return nil + } + page, err := sess.Search(ctx, provider.SearchQuery{Text: title, Kinds: []provider.EntityKind{provider.KindAlbum}, Limit: 10}) + if err != nil { + slog.Debug("artistpage: looking for an album elsewhere", "album", title, "err", err) + return nil + } + want, by := match.Simplify(title), match.Simplify(artist) + for _, a := range page.Albums { + if match.Simplify(a.Title) != want { + continue + } + if by == "" || slices.ContainsFunc(a.Artists, func(c provider.ArtistCredit) bool { return match.Simplify(c.Name) == by }) { + return &a + } + } + return nil +} + // each runs f for 0..n-1, a few at a time. func each(n int, f func(i int)) { sem := make(chan struct{}, parallel) diff --git a/server/internal/artistpage/artistpage_test.go b/server/internal/artistpage/artistpage_test.go index 74746ec..715792e 100644 --- a/server/internal/artistpage/artistpage_test.go +++ b/server/internal/artistpage/artistpage_test.go @@ -214,5 +214,18 @@ func TestElsewhere(t *testing.T) { } } +func TestAlbumElsewhere(t *testing.T) { + sess, caps := open(t, fake.Options{}) + if a := artistpage.AlbumElsewhere(t.Context(), sess, caps, "zero zero", "Null Island"); a == nil || a.Title != "Zero Zero" { + t.Errorf("AlbumElsewhere = %+v", a) + } + if a := artistpage.AlbumElsewhere(t.Context(), sess, caps, "A440", ""); a == nil || a.Title != "A440" { + t.Errorf("without an artist = %+v", a) + } + if a := artistpage.AlbumElsewhere(t.Context(), sess, caps, "Zero Zero", "Sine Language"); a != nil { + t.Errorf("another artist's album found %+v", a) + } +} + // seeded is the same "random" order every run. func seeded() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) } //nolint:gosec // a test's fixed order diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go index e869c0a..4aa4a27 100644 --- a/server/internal/httpapi/api.gen.go +++ b/server/internal/httpapi/api.gen.go @@ -3834,6 +3834,12 @@ type ResetPasswordRequest struct { NewPassword Password `json:"newPassword"` } +// ResolvedRef An artist or album to open with `linkId` and `id`. +type ResolvedRef struct { + Id string `json:"id"` + LinkId string `json:"linkId"` +} + // RestoreQueueItemsRequest defines model for RestoreQueueItemsRequest. type RestoreQueueItemsRequest struct { ItemIds []string `json:"itemIds"` @@ -4991,6 +4997,17 @@ type GetRandomTracksParams struct { Limit *int `form:"limit,omitempty" json:"limit,omitempty"` } +// ResolveAlbumParams defines parameters for ResolveAlbum. +type ResolveAlbumParams struct { + Title string `form:"title" json:"title"` + Artist *string `form:"artist,omitempty" json:"artist,omitempty"` +} + +// ResolveArtistParams defines parameters for ResolveArtist. +type ResolveArtistParams struct { + Name string `form:"name" json:"name"` +} + // GetRoomArtistPlaysParams defines parameters for GetRoomArtistPlays. type GetRoomArtistPlaysParams struct { Name string `form:"name" json:"name"` @@ -5579,6 +5596,12 @@ type ServerInterface interface { // ResetPassword Set a new password with a reset link // (POST /reset-links/{code}/password) ResetPassword(w http.ResponseWriter, r *http.Request, code ResetCode) + // ResolveAlbum Find an album on a service you can use + // (GET /resolve/album) + ResolveAlbum(w http.ResponseWriter, r *http.Request, params ResolveAlbumParams) + // ResolveArtist Find an artist on a service you can use + // (GET /resolve/artist) + ResolveArtist(w http.ResponseWriter, r *http.Request, params ResolveArtistParams) // GetRoomInvite Check a room invite before using it // (GET /room-invites/{code}) GetRoomInvite(w http.ResponseWriter, r *http.Request, code string) @@ -8526,6 +8549,85 @@ func (siw *ServerInterfaceWrapper) ResetPassword(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// ResolveAlbum operation middleware +func (siw *ServerInterfaceWrapper) ResolveAlbum(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ResolveAlbumParams + + // ------------- Required query parameter "title" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "title", r.URL.Query(), ¶ms.Title, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "title"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "title", Err: err}) + } + return + } + + // ------------- Optional query parameter "artist" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "artist", r.URL.Query(), ¶ms.Artist, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "artist"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "artist", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ResolveAlbum(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ResolveArtist operation middleware +func (siw *ServerInterfaceWrapper) ResolveArtist(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ResolveArtistParams + + // ------------- Required query parameter "name" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "name", r.URL.Query(), ¶ms.Name, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "name"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "name", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ResolveArtist(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetRoomInvite operation middleware func (siw *ServerInterfaceWrapper) GetRoomInvite(w http.ResponseWriter, r *http.Request) { @@ -11293,6 +11395,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/artists/{artistId}/tracks", wrapper.GetArtistTracks) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/artists/{artistId}/related", wrapper.GetArtistRelated) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/artists/{artistId}/elsewhere", wrapper.GetArtistElsewhere) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/resolve/artist", wrapper.ResolveArtist) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/resolve/album", wrapper.ResolveAlbum) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/artists/{artistId}/shuffle", wrapper.GetArtistShuffle) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/albums/{albumId}/about", wrapper.GetAlbumAbout) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/genre", wrapper.GetGenre) @@ -15566,6 +15670,84 @@ func (response ResetPassworddefaultJSONResponse) VisitResetPasswordResponse(w ht return err } +type ResolveAlbumRequestObject struct { + Params ResolveAlbumParams +} + +type ResolveAlbumResponseObject interface { + VisitResolveAlbumResponse(w http.ResponseWriter) error +} + +type ResolveAlbum200JSONResponse ResolvedRef + +func (response ResolveAlbum200JSONResponse) VisitResolveAlbumResponse(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 ResolveAlbumdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ResolveAlbumdefaultJSONResponse) VisitResolveAlbumResponse(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 ResolveArtistRequestObject struct { + Params ResolveArtistParams +} + +type ResolveArtistResponseObject interface { + VisitResolveArtistResponse(w http.ResponseWriter) error +} + +type ResolveArtist200JSONResponse ResolvedRef + +func (response ResolveArtist200JSONResponse) VisitResolveArtistResponse(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 ResolveArtistdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ResolveArtistdefaultJSONResponse) VisitResolveArtistResponse(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 GetRoomInviteRequestObject struct { Code string `json:"code"` } @@ -19009,6 +19191,12 @@ type StrictServerInterface interface { // ResetPassword Set a new password with a reset link // (POST /reset-links/{code}/password) ResetPassword(ctx context.Context, request ResetPasswordRequestObject) (ResetPasswordResponseObject, error) + // ResolveAlbum Find an album on a service you can use + // (GET /resolve/album) + ResolveAlbum(ctx context.Context, request ResolveAlbumRequestObject) (ResolveAlbumResponseObject, error) + // ResolveArtist Find an artist on a service you can use + // (GET /resolve/artist) + ResolveArtist(ctx context.Context, request ResolveArtistRequestObject) (ResolveArtistResponseObject, error) // GetRoomInvite Check a room invite before using it // (GET /room-invites/{code}) GetRoomInvite(ctx context.Context, request GetRoomInviteRequestObject) (GetRoomInviteResponseObject, error) @@ -22217,6 +22405,58 @@ func (sh *strictHandler) ResetPassword(w http.ResponseWriter, r *http.Request, c } } +// ResolveAlbum operation middleware +func (sh *strictHandler) ResolveAlbum(w http.ResponseWriter, r *http.Request, params ResolveAlbumParams) { + var request ResolveAlbumRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ResolveAlbum(ctx, request.(ResolveAlbumRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ResolveAlbum") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ResolveAlbumResponseObject); ok { + if err := validResponse.VisitResolveAlbumResponse(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)) + } +} + +// ResolveArtist operation middleware +func (sh *strictHandler) ResolveArtist(w http.ResponseWriter, r *http.Request, params ResolveArtistParams) { + var request ResolveArtistRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ResolveArtist(ctx, request.(ResolveArtistRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ResolveArtist") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ResolveArtistResponseObject); ok { + if err := validResponse.VisitResolveArtistResponse(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)) + } +} + // GetRoomInvite operation middleware func (sh *strictHandler) GetRoomInvite(w http.ResponseWriter, r *http.Request, code string) { var request GetRoomInviteRequestObject diff --git a/server/internal/httpapi/artistpage_test.go b/server/internal/httpapi/artistpage_test.go index c83531f..deceb4c 100644 --- a/server/internal/httpapi/artistpage_test.go +++ b/server/internal/httpapi/artistpage_test.go @@ -100,6 +100,30 @@ func TestArtistElsewhere(t *testing.T) { } } +func TestResolveArtistAndAlbum(t *testing.T) { + e := newEnv(t) + alice := e.admin() + bob := e.member(alice, "bob") + theirs := linkFake(t, bob) + // Bob's link isn't shared yet: Alice has nothing to look on. + alice.want(http.StatusNotFound, "GET", "/resolve/artist?name="+url.QueryEscape("null island"), nil) + bob.want(http.StatusOK, "PATCH", "/links/"+theirs, map[string]any{"shared": true}) + mine := linkFake(t, alice) + + var ref httpapi.ResolvedRef + alice.want(http.StatusOK, "GET", "/resolve/artist?name="+url.QueryEscape("null island"), nil).decode(t, &ref) + if ref.LinkId != mine || ref.Id != "ar2" { + t.Errorf("artist: %+v, want her own link %s", ref, mine) + } + alice.want(http.StatusOK, "GET", "/resolve/album?title="+url.QueryEscape("Zero Zero")+"&artist="+url.QueryEscape("Null Island"), nil).decode(t, &ref) + if ref.LinkId != mine || ref.Id == "" { + t.Errorf("album: %+v", ref) + } + alice.want(http.StatusNotFound, "GET", "/resolve/album?title="+url.QueryEscape("Zero Zero")+"&artist="+url.QueryEscape("Sine Language"), nil) + alice.want(http.StatusNotFound, "GET", "/resolve/artist?name=Null", nil) + alice.want(http.StatusBadRequest, "GET", "/resolve/artist?name=+", nil) +} + func TestRoomArtistPlays(t *testing.T) { e := newEnv(t) alice := e.admin() diff --git a/server/internal/httpapi/guests.go b/server/internal/httpapi/guests.go index 6b31b87..85b977c 100644 --- a/server/internal/httpapi/guests.go +++ b/server/internal/httpapi/guests.go @@ -44,6 +44,9 @@ var guestOps = map[string]bool{ "GetRandomTracks": true, "GetLinkArtwork": true, "GetTrackLyrics": true, + // Artist and album names link to their pages. + "ResolveArtist": true, + "ResolveAlbum": true, // Suggestions come from the links the guest can add from. "GetSuggestions": true, // The room. diff --git a/server/internal/httpapi/resolve.go b/server/internal/httpapi/resolve.go new file mode 100644 index 0000000..4341938 --- /dev/null +++ b/server/internal/httpapi/resolve.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package httpapi + +import ( + "context" + "log/slog" + "strings" + "sync" + + "github.com/madeofpendletonwool/syncphony/server/internal/artistpage" + "github.com/madeofpendletonwool/syncphony/server/internal/links" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" +) + +// Linked names (MAD-837): an artist or album name anywhere in the app opens +// its page. When the name came from a link the viewer can't browse, these +// find it by name on one they can. + +// ResolveArtist finds an artist by name on the caller's links. +func (s *Server) ResolveArtist(ctx context.Context, req ResolveArtistRequestObject) (ResolveArtistResponseObject, error) { + name := strings.TrimSpace(req.Params.Name) + if name == "" { + return nil, &links.InvalidInputError{Field: "name", Message: "name an artist"} + } + ref, err := s.resolve(ctx, func(ctx context.Context, sess provider.Session, caps provider.Capabilities) string { + if a := artistpage.Elsewhere(ctx, sess, caps, name); a != nil { + return a.ID + } + return "" + }) + if err != nil { + return nil, err + } + return ResolveArtist200JSONResponse(ref), nil +} + +// ResolveAlbum finds an album by title, and artist if given, on the +// caller's links. +func (s *Server) ResolveAlbum(ctx context.Context, req ResolveAlbumRequestObject) (ResolveAlbumResponseObject, error) { + title := strings.TrimSpace(req.Params.Title) + if title == "" { + return nil, &links.InvalidInputError{Field: "title", Message: "name an album"} + } + artist := "" + if req.Params.Artist != nil { + artist = strings.TrimSpace(*req.Params.Artist) + } + ref, err := s.resolve(ctx, func(ctx context.Context, sess provider.Session, caps provider.Capabilities) string { + if a := artistpage.AlbumElsewhere(ctx, sess, caps, title, artist); a != nil { + return a.ID + } + return "" + }) + if err != nil { + return nil, err + } + return ResolveAlbum200JSONResponse(ref), nil +} + +// resolve runs find on every link the caller may use at once, and returns +// the first match, the caller's own links before shared ones. +// provider.ErrNotFound when none has it. +func (s *Server) resolve(ctx context.Context, find func(context.Context, provider.Session, provider.Capabilities) string) (ResolvedRef, error) { + // Usable lists the caller's own links first. + ls, err := s.Links.Usable(ctx, sessionFrom(ctx).User.ID) + if err != nil { + return ResolvedRef{}, err + } + found := make([]string, len(ls)) + var wg sync.WaitGroup + for i, l := range ls { + wg.Go(func() { + ctx, cancel := context.WithTimeout(ctx, searchTimeout) + defer cancel() + p, err := s.Links.Provider(l.Provider) + if err != nil { + return + } + sess, err := s.Links.Open(ctx, l.ID) + if err != nil { + slog.Debug("resolve: opening a link", "link", l.ID, "err", err) + return + } + defer sess.Close() + found[i] = find(ctx, sess, p.Info().Capabilities) + }) + } + wg.Wait() + for i, id := range found { + if id != "" { + return ResolvedRef{LinkId: ls[i].ID, Id: id}, nil + } + } + return ResolvedRef{}, provider.ErrNotFound +} diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index dd1c8a0..a02b07b 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -1212,6 +1212,50 @@ export interface paths { patch?: never; trace?: never; }; + "/resolve/artist": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Find an artist on a service you can use + * @description For a name that came from somewhere you can't browse (a song queued + * from someone else's service, or one without an artist ID). Looks + * for the artist by name on your links, then on shared ones, and + * returns the first exact match. 404 when none has it. + */ + get: operations["resolveArtist"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/resolve/album": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Find an album on a service you can use + * @description Like `resolveArtist`, for an album by its title and, when known, its + * artist. + */ + get: operations["resolveAlbum"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/links/{id}/artists/{artistId}/shuffle": { parameters: { query?: never; @@ -4833,6 +4877,11 @@ export interface components { accountLabel: string; artist: components["schemas"]["ArtistResult"]; }; + /** @description An artist or album to open with `linkId` and `id`. */ + ResolvedRef: { + linkId: string; + id: string; + }; AlbumAbout: { kind?: components["schemas"]["AlbumKind"]; /** @description YYYY, YYYY-MM or YYYY-MM-DD. */ @@ -7503,6 +7552,53 @@ export interface operations { default: components["responses"]["Error"]; }; }; + resolveArtist: { + parameters: { + query: { + name: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Where to open the artist */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResolvedRef"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + resolveAlbum: { + parameters: { + query: { + title: string; + artist?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Where to open the album */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResolvedRef"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; getArtistShuffle: { parameters: { query?: { diff --git a/web/src/components/linked-names.test.tsx b/web/src/components/linked-names.test.tsx new file mode 100644 index 0000000..132be14 --- /dev/null +++ b/web/src/components/linked-names.test.tsx @@ -0,0 +1,62 @@ +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 { usableLinksQuery } from '@/lib/services' +import { InRouter } from '@/test/router' +import { AlbumLink, ArtistLinks } from './linked-names' + +const GET = vi.hoisted(() => vi.fn()) +vi.mock('@/api/client', () => ({ api: { GET, POST: vi.fn(), DELETE: vi.fn() } })) +const navigate = vi.hoisted(() => vi.fn()) +vi.mock('@tanstack/react-router', async (load) => ({ + ...(await load()), + useNavigate: () => navigate, +})) + +const ok = (data: unknown) => Promise.resolve({ data, response: new Response(null, { status: 200 }) }) +const missing = () => Promise.resolve({ error: { code: 'not_found', message: 'not found' }, response: new Response(null, { status: 404 }) }) + +function renderIn(ui: React.ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client.setQueryData(usableLinksQuery.queryKey, [{ id: 'mine' }] as never) + render( + + {ui} + , + ) +} + +beforeEach(() => { + GET.mockReset() + navigate.mockReset() +}) +afterEach(cleanup) + +describe('ArtistLinks', () => { + it('links straight to an artist on a link you can use', async () => { + renderIn() + const direct = await screen.findByRole('link', { name: 'Null Island' }) + expect(direct.getAttribute('href')).toBe('/artist/mine/ar2') + // No ID for the second: it's looked up, with a search as its href. + expect(screen.getByRole('link', { name: 'Sine Language' }).getAttribute('href')).toContain('/search?') + expect(GET.mock.calls.filter(([path]) => String(path).startsWith('/resolve'))).toEqual([]) + }) + + it('looks up a name from someone else’s link when tapped', async () => { + GET.mockReturnValue(ok({ linkId: 'mine', id: 'ar9' })) + renderIn() + fireEvent.click(await screen.findByRole('link', { name: 'Null Island' })) + await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: '/artist/$linkId/$artistId', params: { linkId: 'mine', artistId: 'ar9' } })) + expect(GET).toHaveBeenCalledWith('/resolve/artist', { params: { query: { name: 'Null Island' } } }) + }) +}) + +describe('AlbumLink', () => { + it('searches for an album none of your services has', async () => { + GET.mockReturnValue(missing()) + renderIn() + fireEvent.click(await screen.findByRole('link', { name: 'Zero Zero' })) + await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: '/search', search: { q: 'Zero Zero', tab: 'albums' } })) + expect(GET).toHaveBeenCalledWith('/resolve/album', { params: { query: { title: 'Zero Zero', artist: 'Null Island' } } }) + }) +}) diff --git a/web/src/components/linked-names.tsx b/web/src/components/linked-names.tsx new file mode 100644 index 0000000..790c16e --- /dev/null +++ b/web/src/components/linked-names.tsx @@ -0,0 +1,140 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Link, useNavigate } from '@tanstack/react-router' +import { Fragment, useState, type MouseEvent } from 'react' +import type { Track } from '@/lib/now-playing' +import { + albumTarget, + artistTarget, + directLink, + resolveAlbumQuery, + resolveArtistQuery, + trackCredits, + type Credit, + type NameTarget, +} from '@/lib/names' +import { usableLinksQuery } from '@/lib/services' +import { cn } from '@/lib/utils' + +// Artist and album names open their pages (MAD-837). Through the link they +// came from when you can use it; otherwise the name is looked up on your +// services when tapped, and a search for it if none has it. + +const linkClass = + 'rounded-sm underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:ring-3 focus-visible:ring-ring/50' + +function useDirect(linkId: string | undefined, id: string | undefined) { + const links = useQuery({ ...usableLinksQuery, enabled: !!linkId && !!id }) + return directLink(linkId, id, links.data) +} + +// Taps on a name shouldn't also reach the row or card around it. +const stop = (e: MouseEvent) => e.stopPropagation() + +/** A name that's looked up when tapped. Its href is the search, for opening in a new tab. */ +function LookupLink({ + children, + search, + target, + className, +}: { + children: string + search: { q: string; tab: 'artists' | 'albums' } + target: () => Promise + className?: string +}) { + const navigate = useNavigate() + const [busy, setBusy] = useState(false) + return ( + { + e.stopPropagation() + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return + e.preventDefault() + if (busy) return + setBusy(true) + void target() + .then((t) => navigate(t)) + .finally(() => setBusy(false)) + }} + className={cn(linkClass, busy && 'animate-pulse', className)} + > + {children} + + ) +} + +/** One artist's name, as a link to them. */ +export function ArtistLink({ name, id, linkId, className }: Credit & { linkId?: string; className?: string }) { + const qc = useQueryClient() + const direct = useDirect(linkId, id) + if (direct && id) { + return ( + + {name} + + ) + } + return ( + artistTarget(name, () => qc.fetchQuery(resolveArtistQuery(name)))} + className={className} + > + {name} + + ) +} + +type ArtistsProps = ({ track: Pick } | { artists: Credit[]; linkId?: string }) & { + className?: string +} + +/** Every credited artist, comma separated, each a link to them. */ +export function ArtistLinks(props: ArtistsProps) { + const [artists, linkId] = 'track' in props ? [trackCredits(props.track), props.track.linkId] : [props.artists, props.linkId] + return ( + + {artists.map((a, i) => ( + + {i > 0 && ', '} + + + ))} + + ) +} + +type AlbumProps = ( + | { track: Pick } + | { title: string; id?: string; artist?: string; linkId?: string } +) & { className?: string } + +/** An album's title, as a link to it. Nothing for a song whose service didn't name its album. */ +export function AlbumLink(props: AlbumProps) { + const { title, id, artist, linkId } = + 'track' in props + ? { title: props.track.album, id: props.track.albumId, artist: props.track.artists[0], linkId: props.track.linkId } + : props + const qc = useQueryClient() + const direct = useDirect(linkId, id) + if (!title) return null + if (direct && id) { + return ( + + {title} + + ) + } + return ( + albumTarget(title, () => qc.fetchQuery(resolveAlbumQuery(title, artist)))} + className={props.className} + > + {title} + + ) +} diff --git a/web/src/components/shell/app-shell.tsx b/web/src/components/shell/app-shell.tsx index 03a58ff..04443e6 100644 --- a/web/src/components/shell/app-shell.tsx +++ b/web/src/components/shell/app-shell.tsx @@ -1,4 +1,4 @@ -import { Outlet, useRouterState } from '@tanstack/react-router' +import { Outlet, useRouter, useRouterState } from '@tanstack/react-router' import { WifiOff } from 'lucide-react' import { AnimatePresence, LayoutGroup, motion } from 'motion/react' import { useEffect, useState, type ReactNode } from 'react' @@ -28,6 +28,16 @@ import { NowPlayingSheet } from './now-playing-sheet' export function AppShell() { const { nowPlaying } = usePlayer() const [expanded, setExpanded] = useState(false) + // Going to another page (an artist's name in the queue, say) closes now + // playing, so the page isn't opened behind it. + const router = useRouter() + useEffect( + () => + router.subscribe('onBeforeNavigate', (e) => { + if (e.pathChanged) setExpanded(false) + }), + [router], + ) // Lyrics take the artwork's place in now playing. const [lyrics, setLyrics] = useState(false) // Once now playing has slid all the way up it covers everything, so the diff --git a/web/src/components/shell/now-playing-sheet.tsx b/web/src/components/shell/now-playing-sheet.tsx index c0f39d3..f46f94c 100644 --- a/web/src/components/shell/now-playing-sheet.tsx +++ b/web/src/components/shell/now-playing-sheet.tsx @@ -6,7 +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 { AlbumLink, ArtistLinks } from '@/components/track-credits' +import { AlbumLink, ArtistLinks } from '@/components/linked-names' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Slider } from '@/components/ui/slider' @@ -45,7 +45,6 @@ export function NowPlayingSheet({ // While scrubbing, show the thumb where the finger is, not the live position. const [scrub, setScrub] = useState() const queueRef = useRef(null) - const close = () => onOpenChange(false) const beat = useBeat() return ( @@ -171,12 +170,8 @@ export function NowPlayingSheet({ className="min-w-0" > {np.track.title} - - + +
{np.requester && ( diff --git a/web/src/components/track-credits.tsx b/web/src/components/track-credits.tsx deleted file mode 100644 index 9b61fab..0000000 --- a/web/src/components/track-credits.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { Link } from '@tanstack/react-router' -import { Fragment } from 'react' -import type { Track } from '@/lib/now-playing' -import { usableLinksQuery } from '@/lib/services' -import { cn } from '@/lib/utils' - -// Albums and artists open through the link the song was queued with. That -// works for your own links and shared ones; for anyone else's, it searches -// your services for the name instead. - -function useOwnLink(track: Track) { - const links = useQuery(usableLinksQuery) - return track.linkId && links.data?.some((l) => l.id === track.linkId) ? track.linkId : undefined -} - -const linkClass = - 'rounded-sm underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:ring-3 focus-visible:ring-ring/50' - -/** The song's artists, each one a link to them. */ -export function ArtistLinks({ - track, - onNavigate, - className, -}: { - track: Track - onNavigate?: () => void - className?: string -}) { - const linkId = useOwnLink(track) - return ( - - {track.artists.map((name, i) => { - const id = track.artistIds?.[i] - return ( - - {i > 0 && ', '} - {linkId && id ? ( - - {name} - - ) : ( - - {name} - - )} - - ) - })} - - ) -} - -/** The song's album, as a link to it. Nothing if the service didn't say. */ -export function AlbumLink({ - track, - onNavigate, - className, -}: { - track: Track - onNavigate?: () => void - className?: string -}) { - const linkId = useOwnLink(track) - if (!track.album) return null - return linkId && track.albumId ? ( - - {track.album} - - ) : ( - - {track.album} - - ) -} diff --git a/web/src/lib/names.test.ts b/web/src/lib/names.test.ts new file mode 100644 index 0000000..6620920 --- /dev/null +++ b/web/src/lib/names.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { ApiError } from '@/api/errors' +import { albumTarget, artistTarget, directLink, trackCredits } from './names' + +describe('trackCredits', () => { + it('pairs each artist with its ID, leaving out the ones the service didn’t give', () => { + expect(trackCredits({ artists: ['A', 'B', 'C'], artistIds: ['a', ''] })).toEqual([ + { name: 'A', id: 'a' }, + { name: 'B', id: undefined }, + { name: 'C', id: undefined }, + ]) + expect(trackCredits({ artists: ['A'] })).toEqual([{ name: 'A', id: undefined }]) + }) +}) + +describe('directLink', () => { + const usable = [{ id: 'mine' }, { id: 'shared' }] + it('opens through a link you can use', () => { + expect(directLink('shared', 'ar1', usable)).toBe('shared') + }) + it('looks the name up for someone else’s link, a missing ID, or before links load', () => { + expect(directLink('theirs', 'ar1', usable)).toBeUndefined() + expect(directLink('mine', undefined, usable)).toBeUndefined() + expect(directLink(undefined, 'ar1', usable)).toBeUndefined() + expect(directLink('mine', 'ar1', undefined)).toBeUndefined() + }) +}) + +describe('artistTarget and albumTarget', () => { + it('open the page a service has', async () => { + expect(await artistTarget('Null Island', async () => ({ linkId: 'l1', id: 'ar2' }))).toEqual({ + to: '/artist/$linkId/$artistId', + params: { linkId: 'l1', artistId: 'ar2' }, + }) + expect(await albumTarget('Zero Zero', async () => ({ linkId: 'l1', id: 'al3' }))).toEqual({ + to: '/album/$linkId/$albumId', + params: { linkId: 'l1', albumId: 'al3' }, + }) + }) + it('search for the name when no service has it', async () => { + const missing = async () => { + throw new ApiError(404, 'not_found', 'not found') + } + expect(await artistTarget('Null Island', missing)).toEqual({ to: '/search', search: { q: 'Null Island', tab: 'artists' } }) + expect(await albumTarget('Zero Zero', missing)).toEqual({ to: '/search', search: { q: 'Zero Zero', tab: 'albums' } }) + }) +}) diff --git a/web/src/lib/names.ts b/web/src/lib/names.ts new file mode 100644 index 0000000..6982943 --- /dev/null +++ b/web/src/lib/names.ts @@ -0,0 +1,65 @@ +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/api/client' +import { unwrap } from '@/api/errors' +import type { components } from '@/api/schema.gen' + +// Linked names (MAD-837): every artist and album name opens its page. A name +// heard on someone else's service is looked up by name on yours. + +export type ResolvedRef = components['schemas']['ResolvedRef'] + +/** An artist as credited somewhere: the name, and its ID on the link it came from when known. */ +export type Credit = { name: string; id?: string } + +/** A queued song's artists, each with its ID where the service gave one. */ +export function trackCredits(track: { artists: string[]; artistIds?: string[] }): Credit[] { + return track.artists.map((name, i) => ({ name, id: track.artistIds?.[i] || undefined })) +} + +/** The link to open a page through directly: `linkId`, if you can use it and the page's ID is known. */ +export function directLink(linkId: string | undefined, id: string | undefined, usable: { id: string }[] | undefined) { + return linkId && id && usable?.some((l) => l.id === linkId) ? linkId : undefined +} + +const key = (s: string) => s.trim().toLowerCase() + +export const resolveArtistQuery = (name: string) => + queryOptions({ + queryKey: ['resolve', 'artist', key(name)], + queryFn: () => unwrap(api.GET('/resolve/artist', { params: { query: { name } } })), + staleTime: 30 * 60_000, + retry: false, + }) + +export const resolveAlbumQuery = (title: string, artist?: string) => + queryOptions({ + queryKey: ['resolve', 'album', key(title), key(artist ?? '')], + queryFn: () => unwrap(api.GET('/resolve/album', { params: { query: artist ? { title, artist } : { title } } })), + staleTime: 30 * 60_000, + retry: false, + }) + +export type NameTarget = + | { to: '/artist/$linkId/$artistId'; params: { linkId: string; artistId: string } } + | { to: '/album/$linkId/$albumId'; params: { linkId: string; albumId: string } } + | { to: '/search'; search: { q: string; tab: 'artists' | 'albums' } } + +/** Where an artist's name leads once looked up: its page, or a search for it when no service has it. */ +export async function artistTarget(name: string, resolve: () => Promise): Promise { + try { + const r = await resolve() + return { to: '/artist/$linkId/$artistId', params: { linkId: r.linkId, artistId: r.id } } + } catch { + return { to: '/search', search: { q: name, tab: 'artists' } } + } +} + +/** Where an album's title leads once looked up, like `artistTarget`. */ +export async function albumTarget(title: string, resolve: () => Promise): Promise { + try { + const r = await resolve() + return { to: '/album/$linkId/$albumId', params: { linkId: r.linkId, albumId: r.id } } + } catch { + return { to: '/search', search: { q: title, tab: 'albums' } } + } +} diff --git a/web/src/routes/_app/_authed/room.tsx b/web/src/routes/_app/_authed/room.tsx index 604ec1c..ff9d7b9 100644 --- a/web/src/routes/_app/_authed/room.tsx +++ b/web/src/routes/_app/_authed/room.tsx @@ -24,7 +24,7 @@ import { UpNext } from '@/components/room/up-next' import { TransportControls } from '@/components/shell/player-controls' import { SourceTag } from '@/components/service-tag' import { RoomLobby } from '@/components/start-room' -import { AlbumLink, ArtistLinks } from '@/components/track-credits' +import { AlbumLink, ArtistLinks } from '@/components/linked-names' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' diff --git a/web/src/test/router.tsx b/web/src/test/router.tsx new file mode 100644 index 0000000..80b87e6 --- /dev/null +++ b/web/src/test/router.tsx @@ -0,0 +1,14 @@ +import { createMemoryHistory, createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router' +import type { ReactNode } from 'react' + +/** + * Renders ui as the only page of a router, for components with links in + * them. The router mounts asynchronously: find what it shows with findBy*. + */ +export function InRouter({ children }: { children: ReactNode }) { + const router = createRouter({ + routeTree: createRootRoute({ component: () => children }), + history: createMemoryHistory(), + }) + return +} From 5e37ae82bbaa541b9171ffd475781b68ead71edc Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Tue, 13 Oct 2026 15:59:03 -0500 Subject: [PATCH 2/2] Linked names everywhere: queue, playlists, search, discover (MAD-838) Artist and album names now link in queue rows (up next, recently played, my lane, history and stats), track rows (search, album, artist, genre and playlist pages), Syncphony playlists, the album page header, liner notes, and Discover's feed and release cards. Left as plain text: the TV and visualizer (no pointer), game screens, the Wrapped story (taps move between screens), album cards (already a link to the album), and the mini-player (one tap target that opens now playing). Co-Authored-By: Claude Opus 5.5 --- web/src/components/discover/feed-card.tsx | 10 ++++++++-- .../components/discover/release-card.test.tsx | 11 +++++++---- web/src/components/discover/release-card.tsx | 5 ++++- web/src/components/liner-notes-panel.tsx | 4 +++- web/src/components/room/queue-row.tsx | 3 ++- web/src/components/track-row.tsx | 19 ++++--------------- web/src/components/vibe-suggestions.test.tsx | 5 ++++- .../_app/_authed/album.$linkId.$albumId.tsx | 18 ++---------------- .../_app/_authed/library.$playlistId.tsx | 3 ++- 9 files changed, 36 insertions(+), 42 deletions(-) diff --git a/web/src/components/discover/feed-card.tsx b/web/src/components/discover/feed-card.tsx index 327eb86..d901a0d 100644 --- a/web/src/components/discover/feed-card.tsx +++ b/web/src/components/discover/feed-card.tsx @@ -1,6 +1,7 @@ import { ExternalLink, LoaderCircle, Pause, Play, ThumbsDown, ThumbsUp, UserX } from 'lucide-react' import { motion } from 'motion/react' import { Artwork } from '@/components/artwork' +import { AlbumLink, ArtistLink } from '@/components/linked-names' import { Button } from '@/components/ui/button' import { becauseText, imageUrl, togglePreview, usePreview, youtubeUrl, type DiscoveryItem } from '@/lib/discover' import { fadeUp } from '@/lib/motion' @@ -54,8 +55,13 @@ export function FeedCard({

{item.title}

- {item.artist} - {item.album ? ` · ${item.album}` : ''} + + {item.album && ( + <> + {' · '} + + + )}

{why} diff --git a/web/src/components/discover/release-card.test.tsx b/web/src/components/discover/release-card.test.tsx index 7132064..3193e83 100644 --- a/web/src/components/discover/release-card.test.tsx +++ b/web/src/components/discover/release-card.test.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DiscoveryRelease } from '@/lib/discover' import { ReleaseCard } from './release-card' +import { InRouter } from '@/test/router' const GET = vi.hoisted(() => vi.fn()) vi.mock('@/api/client', () => ({ api: { GET, POST: vi.fn(), DELETE: vi.fn() } })) @@ -23,9 +24,11 @@ function renderCard(onBlock = vi.fn()) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( -

    - -
+ +
    + +
+
, ) return onBlock @@ -43,7 +46,7 @@ afterEach(cleanup) describe('ReleaseCard', () => { it('says what it is and why, and lists its songs when opened', async () => { const onBlock = renderCard() - expect(screen.getByText('New')).toBeTruthy() + expect(await screen.findByText('New')).toBeTruthy() expect(screen.getByText(/Album · Out/)).toBeTruthy() expect(screen.getByText(/Near Failure/)).toBeTruthy() expect(GET).not.toHaveBeenCalled() diff --git a/web/src/components/discover/release-card.tsx b/web/src/components/discover/release-card.tsx index 262c050..69fbc48 100644 --- a/web/src/components/discover/release-card.tsx +++ b/web/src/components/discover/release-card.tsx @@ -4,6 +4,7 @@ import { motion } from 'motion/react' import { useId, useState } from 'react' import { errorMessage } from '@/api/errors' import { Artwork } from '@/components/artwork' +import { ArtistLink } from '@/components/linked-names' import { Notice } from '@/components/notice' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' @@ -31,7 +32,9 @@ export function ReleaseCard({ release, isNew, onBlock }: { release: DiscoveryRel {isNew && New} {release.title}

-

{release.artist}

+

+ +

{kindText(release.kind)} · {releaseDateText(release.date)} diff --git a/web/src/components/liner-notes-panel.tsx b/web/src/components/liner-notes-panel.tsx index f81f74b..385c8ff 100644 --- a/web/src/components/liner-notes-panel.tsx +++ b/web/src/components/liner-notes-panel.tsx @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' import { BookOpen, Dices, ExternalLink, Sparkles } from 'lucide-react' import { motion } from 'motion/react' +import { ArtistLink } from '@/components/linked-names' import { Skeleton } from '@/components/ui/skeleton' import { useHidden } from '@/lib/games' import { linerNotesQuery, releaseLine, type LinerNotesFact } from '@/lib/liner-notes' import { fadeUp, stagger } from '@/lib/motion' +import { trackCredits } from '@/lib/names' import type { NowPlaying } from '@/lib/now-playing' import { cn } from '@/lib/utils' @@ -102,7 +104,7 @@ export function LinerNotesPanel({ np, className }: { np: NowPlaying; className?:

- {n.artist.name} + c.name === n.artist?.name)} linkId={np.track.linkId} />

{n.artist.about &&

{n.artist.about}

} {n.artist.bio &&

{n.artist.bio}

} diff --git a/web/src/components/room/queue-row.tsx b/web/src/components/room/queue-row.tsx index d4b6d3d..3a62f27 100644 --- a/web/src/components/room/queue-row.tsx +++ b/web/src/components/room/queue-row.tsx @@ -1,6 +1,7 @@ import { Play } from 'lucide-react' import { useEffect, useRef, useState, type ReactNode } from 'react' import { Artwork } from '@/components/artwork' +import { ArtistLinks } from '@/components/linked-names' import { ServiceGlyph } from '@/components/service-tag' import { AutopilotMark } from '@/components/room/autopilot-badge' import { UserAvatar } from '@/components/user-avatar' @@ -72,7 +73,7 @@ export function QueueRow({

- {item.track.artists.join(', ')} + {byline && user && ( <> {' · '} diff --git a/web/src/components/track-row.tsx b/web/src/components/track-row.tsx index 0a26b61..300c41c 100644 --- a/web/src/components/track-row.tsx +++ b/web/src/components/track-row.tsx @@ -1,4 +1,3 @@ -import { Link } from '@tanstack/react-router' import { motion } from 'motion/react' import { useEffect, useRef } from 'react' import { AddButton } from '@/components/add-button' @@ -7,11 +6,12 @@ import { SaveToPlaylistButton } from '@/components/playlists/save-to-playlist' import { ProviderIcon } from '@/components/provider-icon' import type { LaneStatus } from '@/hooks/use-add-to-lane' import { useMe } from '@/lib/auth' -import { artistNames, artworkUrl, type TrackResult } from '@/lib/browse' +import { artworkUrl, type TrackResult } from '@/lib/browse' import { isGuest } from '@/lib/guests' import { fadeUp } from '@/lib/motion' import { formatDuration } from '@/lib/now-playing' import { cn } from '@/lib/utils' +import { AlbumLink, ArtistLinks } from '@/components/linked-names' type Props = { track: TrackResult @@ -32,7 +32,6 @@ type Props = { /** A song in a list, with its one-tap add, and a bookmark to save it to a playlist. */ export function TrackRow({ track, status, onAdd, providerIcon, number, hideAlbum, note, active }: Props) { const me = useMe() - const albumId = track.album?.id const ref = useRef(null) useEffect(() => { if (active) ref.current?.scrollIntoView({ block: 'nearest' }) @@ -60,22 +59,12 @@ export function TrackRow({ track, status, onAdd, providerIcon, number, hideAlbum

{providerIcon && } - {artistNames(track.artists)} + {note && ` · ${note}`} {!note && !hideAlbum && track.album && ( <> {' · '} - {albumId ? ( - - {track.album.title} - - ) : ( - track.album.title - )} + )} diff --git a/web/src/components/vibe-suggestions.test.tsx b/web/src/components/vibe-suggestions.test.tsx index 68144a9..50e6513 100644 --- a/web/src/components/vibe-suggestions.test.tsx +++ b/web/src/components/vibe-suggestions.test.tsx @@ -7,6 +7,7 @@ import { providersQuery } from '@/lib/services' import type { Suggestion } from '@/lib/suggestions' import { usersQuery } from '@/lib/users' import { VibeSuggestions } from './vibe-suggestions' +import { InRouter } from '@/test/router' const GET = vi.hoisted(() => vi.fn()) vi.mock('@/api/client', () => ({ api: { GET, POST: vi.fn(), DELETE: vi.fn() } })) @@ -37,7 +38,9 @@ function renderVibe() { chooseRoom('r1') return render( - + + + , ) } diff --git a/web/src/routes/_app/_authed/album.$linkId.$albumId.tsx b/web/src/routes/_app/_authed/album.$linkId.$albumId.tsx index 0f82251..b33f120 100644 --- a/web/src/routes/_app/_authed/album.$linkId.$albumId.tsx +++ b/web/src/routes/_app/_authed/album.$linkId.$albumId.tsx @@ -7,6 +7,7 @@ import { AlbumCard } from '@/components/album-card' import { Bio, GenreChips, PageSection } from '@/components/artist-parts' import { Artwork } from '@/components/artwork' import { BackButton } from '@/components/back-button' +import { ArtistLinks } from '@/components/linked-names' import { Notice } from '@/components/notice' import { ProviderIcon } from '@/components/provider-icon' import { TrackRow } from '@/components/track-row' @@ -87,22 +88,7 @@ function Album() { {a.title} - {a.artists.map((ar, i) => ( - - {i > 0 && ', '} - {ar.id ? ( - - {ar.name} - - ) : ( - ar.name - )} - - ))} + {p && } diff --git a/web/src/routes/_app/_authed/library.$playlistId.tsx b/web/src/routes/_app/_authed/library.$playlistId.tsx index f0685ad..a4aa631 100644 --- a/web/src/routes/_app/_authed/library.$playlistId.tsx +++ b/web/src/routes/_app/_authed/library.$playlistId.tsx @@ -8,6 +8,7 @@ import { AddButton } from '@/components/add-button' import { Artwork } from '@/components/artwork' import { BackButton } from '@/components/back-button' import { ConfirmButton } from '@/components/confirm-button' +import { ArtistLinks } from '@/components/linked-names' import { Notice } from '@/components/notice' import { PlaylistCover } from '@/components/playlists/playlist-cover' import { Button } from '@/components/ui/button' @@ -316,7 +317,7 @@ function SongRow({

{s.track.title}

{by && } - {s.track.artists.join(', ')} +

{editing ? (