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