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}` : ''}
+
{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(
-
+
+
+
{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.about} {n.artist.bio}
{n.artist.about &&