From 784977045ccb502f079969fa840723b30784ec9a Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Tue, 13 Oct 2026 10:31:55 -0500 Subject: [PATCH 1/2] Personal discovery: the map API (MAD-820) GET /me/discover/map draws a graph around any artist, or around a set's seeds: neighbors near the most seeds first, and two steps out around an artist. Each artist says whether you have anything of theirs, whether they're a seed or the center, how many seeds they're near, and their fans, picture and tags. Each draws its three strongest likenesses, and artists you blocked are left out. It reads the music graph's cache and waits at most 5 seconds on the few artists it fetches. A slow fetch finishes in the background, the rest are warmed, and the map says it's partial so a reload shows more. Warm, it takes tens of milliseconds. GET /me/discover/songs lists an artist's songs you don't have, with previews, for the map's artist sheet. Co-Authored-By: Claude Opus 5.5 --- api/openapi.yaml | 187 ++++++++++ docs/adr/0017-personal-discovery.md | 16 +- server/internal/discover/feed.go | 118 ++++-- server/internal/discover/map.go | 438 +++++++++++++++++++++++ server/internal/discover/map_test.go | 182 ++++++++++ server/internal/httpapi/api.gen.go | 375 +++++++++++++++++++ server/internal/httpapi/discover.go | 73 +++- server/internal/httpapi/discover_test.go | 22 +- 8 files changed, 1374 insertions(+), 37 deletions(-) create mode 100644 server/internal/discover/map.go create mode 100644 server/internal/discover/map_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 9c29bec..c60d075 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3156,6 +3156,92 @@ paths: default: $ref: "#/components/responses/Error" + /me/discover/map: + get: + operationId: getDiscoveryMap + summary: A map of artists around an artist or a set + description: | + A graph in the spirit of music-map.com: give `artist` (any artist, + not just yours) or `setId`. Around an artist, its neighbors and, + at depth 2, their nearest neighbors. Around a set, its seeds and + their neighbors, those near the most seeds first. Edges say how + alike two artists are. Artists you blocked are left out, unless + the map is of them. It reads the music graph's cache: artists not + known yet are looked up in the background (`partial`), and loading + the map again shows more. + tags: [discover] + parameters: + - name: artist + in: query + description: The artist's name. Give this or `setId`. + schema: + type: string + maxLength: 200 + - name: mbid + in: query + description: The artist's MusicBrainz ID, when known. + schema: + type: string + - name: setId + in: query + description: One of your sets. Give this or `artist`. + schema: + type: string + - name: depth + in: query + description: Steps out from the artist or seeds. Default 2 around an artist, 1 around a set. + schema: + type: integer + minimum: 1 + maximum: 2 + - name: limit + in: query + description: How many artists, at most. Default 60. + schema: + type: integer + minimum: 10 + maximum: 100 + responses: + "200": + description: The map + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoveryMap" + default: + $ref: "#/components/responses/Error" + + /me/discover/songs: + get: + operationId: listDiscoveryArtistSongs + summary: An artist's songs you don't have + description: | + Their best-known songs, without the ones you have, turned down or + blocked: for an artist on the map. Each has a preview when Deezer + has one. + tags: [discover] + parameters: + - name: artist + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 200 + - name: mbid + in: query + schema: + type: string + responses: + "200": + description: The songs + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoverySongs" + default: + $ref: "#/components/responses/Error" + /me/discover/library: get: operationId: getDiscoveryLibrary @@ -8408,6 +8494,107 @@ components: type: boolean description: The server knows nothing of how music relates (no sources are on), so feeds are empty. + DiscoveryMapNode: + type: object + description: An artist on a map. + required: [key, name, tags, owned, seed, center, blocked, hop, seeds, score, known] + properties: + key: + type: string + description: How the artist is compared, and what edges name. + name: + type: string + mbid: + type: string + fans: + type: integer + format: int64 + description: Fans on Deezer, a measure of how well known. + picture: + type: string + description: Their picture's image ID, for getDiscoveryImage (kind `artist`). + tags: + type: array + description: Their strongest tags. + items: + type: string + owned: + type: boolean + description: You have something of theirs on your own Navidrome. + seed: + type: boolean + description: One of the set's seeds. + center: + type: boolean + description: The artist the map is of. + blocked: + type: boolean + description: You said they aren't for you. Only ever the artist the map is of, or a seed. + hop: + type: integer + description: Steps from the artist or seeds; 0 for those. + seeds: + type: integer + description: How many of the set's seeds they're near. + score: + type: number + description: How near, 0 to 1; 1 for the artist or seeds. + known: + type: boolean + description: The music graph knows them yet. One it doesn't is being looked up. + + DiscoveryMapEdge: + type: object + required: [from, to, score, sources] + properties: + from: + type: string + to: + type: string + score: + type: number + description: How alike, 0 to 1. + sources: + type: array + items: + type: string + + DiscoveryMap: + type: object + required: [nodes, edges, partial, noGraph, library] + properties: + set: + $ref: "#/components/schemas/DiscoverySet" + nodes: + type: array + description: The artist or seeds first, then the rest, nearest first. + items: + $ref: "#/components/schemas/DiscoveryMapNode" + edges: + type: array + items: + $ref: "#/components/schemas/DiscoveryMapEdge" + partial: + type: boolean + description: Some artists aren't known yet and are being looked up; load the map again shortly for more. + noGraph: + type: boolean + library: + $ref: "#/components/schemas/DiscoveryLibrary" + + DiscoverySongs: + type: object + required: [items, library, noGraph] + properties: + items: + type: array + items: + $ref: "#/components/schemas/DiscoveryItem" + library: + $ref: "#/components/schemas/DiscoveryLibrary" + noGraph: + type: boolean + DiscoveryFeedbackRequest: type: object required: [kind, title, artist] diff --git a/docs/adr/0017-personal-discovery.md b/docs/adr/0017-personal-discovery.md index 7a72f97..41d8e2c 100644 --- a/docs/adr/0017-personal-discovery.md +++ b/docs/adr/0017-personal-discovery.md @@ -2,7 +2,7 @@ - **Status:** accepted - **Date:** 2026-10-13 -- **Issues:** MAD-814 (Phase 12), stage 1: MAD-815..819 +- **Issues:** MAD-814 (Phase 12), stage 1: MAD-815..819, stage 2: MAD-820, 821 ## Context @@ -43,6 +43,20 @@ Top songs from Deezer carry their Deezer ID, album and cover (`musicgraph.SongRe "Open on YouTube" links to `https://www.youtube.com/results?search_query= ` in a new tab. It needs no API key, no embed and no yt-dlp. There are no store or buy links. +### The map + +`GET /me/discover/map` (MAD-820) draws a graph in the spirit of music-map.com, around any artist (`artist`, with `mbid` if known) or around one of your sets (`setId`). + +- **Around an artist** (depth 2 by default): half the room goes to its nearest neighbors, the rest to their own nearest (four each, damped as the feed's second step is), then more neighbors if that's not enough. +- **Around a set** (depth 1 by default): the seeds, and the union of their neighbors, the ones near the most seeds first, then the nearest. +- **Bounded:** 60 artists by default (10 to 100). Each artist draws only its three strongest likenesses, so sixty artists don't make a hairball. The graph's merged score sets how alike they are. +- **Fast once warm:** the map reads the music graph's cache. It fetches at most three artists that aren't cached (the artist it's of, or the first seeds), and waits for them at most 5 seconds. A fetch still going after that carries on in the background and is cached. Everyone else who isn't cached is warmed, and the map says it's `partial`, so the page loads it again a few times as it fills out. A warm map takes tens of milliseconds. +- **What each artist says:** whether you have anything of theirs (`KnowsArtist`: for showing only), whether they're a seed or the center, how many seeds they're near, their Deezer fans, picture and top tags, and whether the graph knows them yet. Artists you blocked are left out, unless the map is of them. + +`GET /me/discover/songs?artist=` gives an artist's best-known songs that you don't have, haven't turned down and don't want yet, with previews. It's for the sheet that opens when you tap an artist on the map. + +The **Map** view (MAD-821) is a tab beside a set's feed, and `/discover/map?path=…` for any artist. It's laid out with d3-force, all at once off screen. Distance follows how alike two artists are, and size follows how well known they are. It's drawn in SVG, and a new layout starts from where the last one left each artist, then tweens there, so re-centering glides. Artists you have are dimmed, new ones are in full color, and seeds and the center are ringed. In a set's map, artists near several seeds get a soft halo. Each re-center is a step in the URL, so Back and the breadcrumbs retrace them. There's nothing beat-reactive: the app shell stays calm. + ### Feedback stays out of rooms Discovery thumbs and blocks never reach a room's DJ (`dj_affinity`), and a room's skips never touch a set. Stage 1 stores thumbs-up. Stage 4 turns them into Want-list entries and growing sets. diff --git a/server/internal/discover/feed.go b/server/internal/discover/feed.go index 104d957..35bfc05 100644 --- a/server/internal/discover/feed.go +++ b/server/internal/discover/feed.go @@ -275,31 +275,8 @@ func (s *Service) build(ctx context.Context, userID string, set Set) [][]Item { keep = func(Item) bool { return true } } - known := map[string]musicgraph.Artist{} - var missing []musicgraph.ArtistRef - fetches := 0 - lookup := func(ref musicgraph.ArtistRef) (musicgraph.Artist, bool) { - k := library.ArtistKey(ref.Name) - if a, ok := known[k]; ok { - return a, true - } - a, ok, err := s.Graph.CachedArtist(ctx, ref) - if !ok && err == nil && fetches < maxFetches && ctx.Err() == nil { - fetches++ - a, err = s.Graph.Artist(ctx, ref) - ok = err == nil - } - switch { - case ok && err == nil: - known[k] = a - return a, true - case !ok && err == nil: - missing = append(missing, ref) - case !errors.Is(err, provider.ErrNotFound) && ctx.Err() == nil: - slog.Debug("discover: looking up an artist", "artist", ref.Name, "err", err) - } - return musicgraph.Artist{}, false - } + lk := &looker{g: s.Graph, ctx: ctx, budget: maxFetches} + lookup := func(ref musicgraph.ArtistRef) (musicgraph.Artist, bool) { return lk.get(ref, true) } names := map[string]string{} nodes := map[string]*node{} @@ -404,12 +381,92 @@ func (s *Service) build(ctx context.Context, userID string, set Set) [][]Item { } add(n, seedPull) } - if len(missing) > 0 { - s.Graph.WarmArtist(missing...) - } + lk.warm() return arrange(items) } +// looker looks artists up in the graph for one walk: from the cache, or +// fetched while there's budget left. Those it couldn't find are warmed +// for next time. +type looker struct { + g Graph + ctx context.Context + // budget is how many artists that aren't cached it fetches, and + // fetchBy, if set, how long it waits for one: a fetch still going + // then carries on in the background, and the artist counts as + // missing. + budget int + fetchBy time.Time + fetches int + known map[string]musicgraph.Artist + missing []musicgraph.ArtistRef +} + +// get returns what's known of an artist. fetch says whether one that +// isn't cached may be fetched, within the budget; if not, they're warmed. +func (l *looker) get(ref musicgraph.ArtistRef, fetch bool) (musicgraph.Artist, bool) { + k := library.ArtistKey(ref.Name) + if a, ok := l.known[k]; ok { + return a, true + } + a, ok, err := l.g.CachedArtist(l.ctx, ref) + if !ok && err == nil && fetch && l.fetches < l.budget && l.ctx.Err() == nil && (l.fetchBy.IsZero() || time.Now().Before(l.fetchBy)) { + l.fetches++ + a, ok, err = l.fetch(ref) + } + switch { + case ok && err == nil: + if l.known == nil { + l.known = map[string]musicgraph.Artist{} + } + l.known[k] = a + return a, true + case !ok && err == nil: + if !slices.ContainsFunc(l.missing, func(m musicgraph.ArtistRef) bool { return library.ArtistKey(m.Name) == k }) { + l.missing = append(l.missing, ref) + } + case !errors.Is(err, provider.ErrNotFound) && l.ctx.Err() == nil: + slog.Debug("discover: looking up an artist", "artist", ref.Name, "err", err) + } + return musicgraph.Artist{}, false +} + +func (l *looker) fetch(ref musicgraph.ArtistRef) (musicgraph.Artist, bool, error) { + if l.fetchBy.IsZero() { + a, err := l.g.Artist(l.ctx, ref) + return a, err == nil, err + } + type answer struct { + a musicgraph.Artist + err error + } + got := make(chan answer, 1) + go func() { + // Not cut short: what it finds is cached for next time. + a, err := l.g.Artist(context.WithoutCancel(l.ctx), ref) + got <- answer{a, err} + }() + wait := time.NewTimer(time.Until(l.fetchBy)) + defer wait.Stop() + select { + case r := <-got: + return r.a, r.err == nil, r.err + case <-wait.C: + return musicgraph.Artist{}, false, nil + case <-l.ctx.Done(): + return musicgraph.Artist{}, false, l.ctx.Err() + } +} + +// warm asks for the artists it couldn't find to be fetched, in the +// background. It reports whether there were any. +func (l *looker) warm() bool { + if len(l.missing) > 0 { + l.g.WarmArtist(l.missing...) + } + return len(l.missing) > 0 +} + // famous is how well known an artist is, from their fans on Deezer, 0 to // 1: ten million fans is as famous as it gets. Unknown is middling. func famous(fans int64) float64 { @@ -513,7 +570,7 @@ func arrange(items []Item) [][]Item { // resolve finds the Deezer IDs of a page's songs that have none, so they // can play a preview: from the database, or by searching Deezer once. -// What it finds is kept in the feed too. +// What it finds is kept in the feed too, if there is one. func (s *Service) resolve(ctx context.Context, f *feed, items []Item) { if s.Deezer == nil { return @@ -548,6 +605,9 @@ func (s *Service) resolve(ctx context.Context, f *feed, items []Item) { } it.DeezerID, it.Album, it.Cover = row.DeezerID, cmp.Or(it.Album, row.Album), cmp.Or(it.Cover, row.Cover) } + if f == nil { + return + } // Keep what was found, for the next time the page is served. found := map[string]Item{} for _, it := range items { diff --git a/server/internal/discover/map.go b/server/internal/discover/map.go new file mode 100644 index 0000000..235b791 --- /dev/null +++ b/server/internal/discover/map.go @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package discover + +import ( + "cmp" + "context" + "slices" + "strings" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/library" + "github.com/madeofpendletonwool/syncphony/server/internal/match" + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" +) + +// The map (MAD-820): a graph of artists around one artist, or around a +// set's seeds, in the spirit of music-map.com. It reads the music graph's +// cache and fetches little: what isn't cached yet is warmed, so the map +// fills out when it's loaded again. +const ( + // DefaultMapLimit is how many artists a map shows, MaxMapLimit and + // MinMapLimit how many it may be asked for. + DefaultMapLimit = 60 + MaxMapLimit = 100 + MinMapLimit = 10 + // mapFetches is how many artists that aren't cached a map fetches: + // the artist it's of, or the first seeds. The rest are warmed. + mapFetches = 3 + // mapFetchFor bounds fetching for a map; what isn't found by then is + // warmed, and the map says it's partial. + mapFetchFor = 5 * time.Second + // mapEdges is how many of each artist's strongest likenesses a map + // draws, so sixty artists don't make a hairball. + mapEdges = 3 + // mapPerHop2 is how many of a neighbor's nearest artists a map two + // steps out takes. + mapPerHop2 = 4 + // mapTags is how many tags a node carries. + mapTags = 3 + // songsFor bounds looking up an artist's songs. + songsFor = 10 * time.Second +) + +// MapQuery is what a map is of: an artist (by name, and MBID if known) or +// one of the user's sets. +type MapQuery struct { + Artist, MBID string + SetID string + // Depth is how many steps out from the artist or seeds: 1 or 2. 0 is + // the default: 2 around an artist, 1 around a set. + Depth int + // Limit is how many artists, at most; 0 is DefaultMapLimit. + Limit int +} + +// MapNode is an artist on a map. +type MapNode struct { + // Key is how the artist is compared (library.ArtistKey), and what + // edges name. + Key, Name, MBID string + // Fans is how many fans they have on Deezer, Picture their picture's + // ID there; zero if unknown. + Fans int64 + Picture string + // Tags are their strongest tags. + Tags []string + // Owned is whether the user has anything of theirs. Seed is whether + // they're one of the set's seeds, Center whether the map is of them. + // Blocked is whether the user said they aren't for them: only ever an + // artist the map is of, or a seed, since others blocked are left out. + Owned, Seed, Center, Blocked bool + // Hop is how many steps they are from the artist or seeds. + Hop int + // Seeds is how many of the set's seeds they're near. + Seeds int + // Score is how near they are, 0 to 1: 1 for the artist or seeds. + Score float64 + // Known is whether the music graph knows them yet. One it doesn't is + // being looked up, and has no picture, tags or neighbors yet. + Known bool +} + +// MapEdge says two artists on a map are alike. +type MapEdge struct { + From, To string + // Score is how alike, 0 to 1. + Score float64 + Sources []string +} + +// Map is a graph of artists. +type Map struct { + // Set is the set it's of, if it's of one. + Set *Set + Nodes []MapNode + Edges []MapEdge + // Partial is whether some artists weren't known yet: they're being + // looked up, and loading the map again shows more. + Partial bool + // NoGraph is whether the server knows nothing of how music relates. + NoGraph bool + // Library is how far the user's library is indexed: until it's done, + // artists they have may not show as theirs. + Library library.Status +} + +// Map returns a graph around an artist, or around a set's seeds. Artists +// the user blocked are left out, unless the map is of them. +func (s *Service) Map(ctx context.Context, userID string, q MapQuery) (Map, error) { + q.Artist = strings.Join(strings.Fields(q.Artist), " ") + switch { + case (q.Artist == "") == (q.SetID == ""): + return Map{}, &InvalidInputError{"a map is of an artist or a set"} + case q.Depth < 0 || q.Depth > 2: + return Map{}, &InvalidInputError{"depth is 1 or 2"} + case q.Limit != 0 && (q.Limit < MinMapLimit || q.Limit > MaxMapLimit): + return Map{}, &InvalidInputError{"limit is 10 to 100"} + } + limit := cmp.Or(q.Limit, DefaultMapLimit) + var out Map + var roots []musicgraph.ArtistRef + if q.SetID != "" { + set, err := s.Get(ctx, userID, q.SetID) + if err != nil { + return Map{}, err + } + out.Set = &set + for _, a := range set.Artists { + roots = append(roots, musicgraph.ArtistRef{Name: a.Name, MBID: a.Mbid.String}) + } + q.Depth = cmp.Or(q.Depth, 1) + } else { + roots = []musicgraph.ArtistRef{{Name: q.Artist, MBID: strings.ToLower(strings.TrimSpace(q.MBID))}} + q.Depth = cmp.Or(q.Depth, 2) + } + + var owned *library.Owned + if s.Library != nil { + o, err := s.Library.For(ctx, userID) + if err != nil { + return Map{}, err + } + owned, out.Library = o, o.Status() + } + blocks, err := s.db.ListDiscoveryBlocks(ctx, userID) + if err != nil { + return Map{}, err + } + blocked := map[string]bool{} + for _, b := range blocks { + blocked[b.ArtistKey] = true + } + + m := &mapper{roots: map[string]*node{}, nodes: map[string]*node{}, blocked: blocked} + if s.Graph == nil { + out.NoGraph = true + } else { + m.lk = &looker{g: s.Graph, ctx: ctx, budget: mapFetches, fetchBy: time.Now().Add(mapFetchFor)} + } + chosen := m.walk(roots, q.Depth, limit) + + // The nodes, the roots first, and what the graph knows of each. + top := 0.0 + for _, n := range chosen { + top = max(top, n.pull()) + } + center := q.SetID == "" + add := func(n *node, root bool) { + k := library.ArtistKey(n.ref.Name) + mn := MapNode{ + Key: k, Name: n.ref.Name, MBID: n.ref.MBID, Owned: owned.KnowsArtist(n.ref.Name), + Seed: root && !center, Center: root && center, Blocked: blocked[k], Hop: n.hop, Seeds: len(n.near), Score: 1, + } + if root { + mn.Seeds = 0 + } else { + mn.Score = n.pull() / top + } + if a, ok := m.get(n.ref); ok { + mn.Known, mn.Fans, mn.Picture = true, a.Fans, a.Picture + mn.Name = cmp.Or(a.Ref.Name, mn.Name) + mn.MBID = cmp.Or(mn.MBID, a.Ref.MBID) + for _, t := range a.Tags[:min(len(a.Tags), mapTags)] { + mn.Tags = append(mn.Tags, t.Name) + } + } + out.Nodes = append(out.Nodes, mn) + } + for _, n := range m.order { + add(n, true) + } + for _, n := range chosen { + add(n, false) + } + out.Edges = m.edges(out.Nodes) + if m.lk != nil { + out.Partial = m.lk.warm() + } + return out, nil +} + +// mapper walks the graph for a map. +type mapper struct { + lk *looker + // roots are the artist the map is of, or the seeds, by key; order is + // them as given. + roots map[string]*node + order []*node + // nodes are the artists reached, not roots. + nodes map[string]*node + blocked map[string]bool +} + +// get returns what the graph knows of an artist. Only the roots are +// fetched if they aren't cached; anyone else is warmed. +func (m *mapper) get(ref musicgraph.ArtistRef) (musicgraph.Artist, bool) { + if m.lk == nil { + return musicgraph.Artist{}, false + } + _, root := m.roots[library.ArtistKey(ref.Name)] + return m.lk.get(ref, root) +} + +// reach records that a walk reached an artist: near root by near, hop +// steps out. Roots and blocked artists are passed over. +func (m *mapper) reach(into map[string]*node, ref musicgraph.ArtistRef, root string, near float64, hop int) { + k := library.ArtistKey(ref.Name) + if _, isRoot := m.roots[k]; k == "" || isRoot || m.blocked[k] { + return + } + n, ok := into[k] + if !ok { + n = &node{ref: ref, near: map[string]float64{}, hop: hop} + into[k] = n + } + n.near[root] = max(n.near[root], near) + n.hop = min(n.hop, hop) + n.ref.MBID = cmp.Or(n.ref.MBID, ref.MBID) +} + +// walk returns the artists to show besides the roots, up to limit in all +// (and always some, however many roots): the roots' neighbors near the +// most roots first, and, two steps out, their nearest neighbors' too. +func (m *mapper) walk(roots []musicgraph.ArtistRef, depth, limit int) []*node { + for _, ref := range roots { + k := library.ArtistKey(ref.Name) + if _, dup := m.roots[k]; k == "" || dup { + continue + } + n := &node{ref: ref, near: map[string]float64{k: 1}, seed: true} + m.roots[k] = n + m.order = append(m.order, n) + } + slots := max(limit-len(m.order), MinMapLimit) + for _, n := range m.order { + k := library.ArtistKey(n.ref.Name) + a, ok := m.get(n.ref) + if !ok { + continue + } + for _, sim := range a.Similar[:min(len(a.Similar), max(slots, similarPerSeed))] { + m.reach(m.nodes, sim.Artist, k, sim.Score, 1) + } + } + hop1 := byOverlap(m.nodes) + if depth < 2 { + return hop1[:min(len(hop1), slots)] + } + // Two steps out: half the room for the nearest neighbors, the rest + // for their own nearest, then more neighbors if that's not enough. + chosen := hop1[:min(len(hop1), (slots+1)/2)] + taken := map[string]bool{} + for _, n := range chosen { + taken[library.ArtistKey(n.ref.Name)] = true + } + far := map[string]*node{} + for _, n := range chosen { + a, ok := m.get(n.ref) + if !ok { + continue + } + root, pull := strongest(n.near) + got := 0 + for _, sim := range a.Similar { + if got == mapPerHop2 { + break + } + k := library.ArtistKey(sim.Artist.Name) + if _, isRoot := m.roots[k]; isRoot || taken[k] || m.blocked[k] { + continue + } + m.reach(far, sim.Artist, root, pull*sim.Score*hop2Damping, 2) + got++ + } + } + for _, n := range byOverlap(far) { + if len(chosen) == slots { + break + } + chosen = append(chosen, n) + taken[library.ArtistKey(n.ref.Name)] = true + } + for _, n := range hop1 { + if len(chosen) == slots { + break + } + if !taken[library.ArtistKey(n.ref.Name)] { + chosen = append(chosen, n) + } + } + return chosen +} + +// byOverlap returns nodes near the most roots first, then nearest. +func byOverlap(nodes map[string]*node) []*node { + out := ranked(nodes) + slices.SortStableFunc(out, func(a, b *node) int { return cmp.Compare(len(b.near), len(a.near)) }) + return out +} + +// edges are the likenesses the graph knows between a map's artists, each +// pair once, as alike as the most alike source says: those among either +// artist's mapEdges strongest. +func (m *mapper) edges(nodes []MapNode) []MapEdge { + on := map[string]bool{} + for _, n := range nodes { + on[n.Key] = true + } + type pair struct{ a, b string } + found := map[pair]*MapEdge{} + var out []*MapEdge + for _, n := range nodes { + if !n.Known { + continue + } + a, _ := m.get(musicgraph.ArtistRef{Name: n.Name, MBID: n.MBID}) + for _, sim := range a.Similar { + k := library.ArtistKey(sim.Artist.Name) + if !on[k] || k == n.Key { + continue + } + p := pair{n.Key, k} + if p.b < p.a { + p.a, p.b = p.b, p.a + } + e, ok := found[p] + if !ok { + e = &MapEdge{From: n.Key, To: k} + found[p] = e + out = append(out, e) + } + e.Score = max(e.Score, sim.Score) + for _, src := range sim.Sources { + if !slices.Contains(e.Sources, src) { + e.Sources = append(e.Sources, src) + } + } + } + } + // Each artist's strongest. + byNode := map[string][]*MapEdge{} + for _, e := range out { + byNode[e.From] = append(byNode[e.From], e) + byNode[e.To] = append(byNode[e.To], e) + } + keep := map[*MapEdge]bool{} + for _, es := range byNode { + slices.SortStableFunc(es, func(a, b *MapEdge) int { return cmp.Compare(b.Score, a.Score) }) + for _, e := range es[:min(len(es), mapEdges)] { + keep[e] = true + } + } + edges := make([]MapEdge, 0, len(keep)) + for _, e := range out { + if !keep[e] { + continue + } + if e.Sources == nil { + e.Sources = []string{} + } + edges = append(edges, *e) + } + return edges +} + +// Songs are an artist's songs the user doesn't have. +type Songs struct { + Items []Item + Library library.Status + NoGraph bool +} + +// ArtistSongs returns an artist's best-known songs that the user doesn't +// have, hasn't turned down and doesn't want yet: for a map's artist. +// Nothing if they blocked the artist. +func (s *Service) ArtistSongs(ctx context.Context, userID, name, mbid string) (Songs, error) { + name = strings.Join(strings.Fields(name), " ") + if library.ArtistKey(name) == "" { + return Songs{}, &InvalidInputError{"an artist's name is needed"} + } + out := Songs{Items: []Item{}, NoGraph: s.Graph == nil} + keep, status, err := s.filter(ctx, userID) + if err != nil { + return Songs{}, err + } + out.Library = status + if s.Graph == nil { + return out, nil + } + lctx, cancel := context.WithTimeout(ctx, songsFor) + defer cancel() + lk := &looker{g: s.Graph, ctx: lctx, budget: 1} + a, ok := lk.get(musicgraph.ArtistRef{Name: name, MBID: strings.ToLower(strings.TrimSpace(mbid))}, true) + lk.warm() + if !ok { + return out, nil + } + for _, so := range a.Top { + if len(out.Items) == songsPerArtist { + break + } + artist := cmp.Or(so.Artist.Name, a.Ref.Name, name) + if _, variants := match.Title(so.Title); len(variants) > 0 || so.Title == "" { + continue + } + it := Item{ + Key: SongKey(artist, so.Title), Title: so.Title, Artist: artist, Album: so.Album, + MBID: so.MBID, ISRC: so.ISRC, DeezerID: so.DeezerID, Cover: so.Cover, Because: []string{}, Score: so.Score, + } + if !keep(it) || slices.ContainsFunc(out.Items, func(o Item) bool { return o.Key == it.Key }) { + continue + } + out.Items = append(out.Items, it) + } + s.resolve(ctx, nil, out.Items) + return out, nil +} diff --git a/server/internal/discover/map_test.go b/server/internal/discover/map_test.go new file mode 100644 index 0000000..3460f65 --- /dev/null +++ b/server/internal/discover/map_test.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package discover_test + +import ( + "fmt" + "slices" + "testing" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/discover" + "github.com/madeofpendletonwool/syncphony/server/internal/library" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" +) + +func node(m discover.Map, name string) (discover.MapNode, bool) { + i := slices.IndexFunc(m.Nodes, func(n discover.MapNode) bool { return n.Name == name }) + if i < 0 { + return discover.MapNode{}, false + } + return m.Nodes[i], true +} + +func edge(m discover.Map, a, b string) bool { + ka, kb := library.ArtistKey(a), library.ArtistKey(b) + return slices.ContainsFunc(m.Edges, func(e discover.MapEdge) bool { + return (e.From == ka && e.To == kb) || (e.From == kb && e.To == ka) + }) +} + +func TestMapOfASet(t *testing.T) { + e := newEnv(t) + credit := []provider.ArtistCredit{{Name: "Lush"}} + e.svc.Library = owned{library.Holding(provider.Track{Title: "Sweetness and Light", Artists: credit})} + if _, err := e.svc.Block(t.Context(), e.alice, "Shiner"); err != nil { + t.Fatal(err) + } + set := e.set(t, "Slowdive", "Hum", "Duster") + m, err := e.svc.Map(t.Context(), e.alice, discover.MapQuery{SetID: set.ID}) + if err != nil { + t.Fatal(err) + } + if m.Set == nil || m.Set.ID != set.ID { + t.Fatalf("set: %+v", m.Set) + } + // The seeds first, ringed; then the artist near all three. + for i, name := range []string{"Slowdive", "Hum", "Duster"} { + if n := m.Nodes[i]; n.Name != name || !n.Seed || n.Center || n.Score != 1 { + t.Errorf("node %d: %+v", i, n) + } + } + if n := m.Nodes[3]; n.Name != "Swirlies" || n.Seeds != 3 || n.Hop != 1 { + t.Errorf("first neighbor: %+v", n) + } + if n, ok := node(m, "Failure"); !ok || n.Seeds != 2 { + t.Errorf("Failure, near Hum and Duster: %+v", n) + } + if n, _ := node(m, "Lush"); !n.Owned { + t.Error("Lush isn't shown as owned") + } + if n, _ := node(m, "Ride"); n.Owned || !n.Known || n.Fans != 100_000 { + t.Errorf("Ride: %+v", n) + } + if _, ok := node(m, "Shiner"); ok { + t.Error("a blocked artist is on the map") + } + if !edge(m, "Slowdive", "Swirlies") || !edge(m, "Hum", "Swirlies") || !edge(m, "Slowdive", "Hum") { + t.Errorf("edges: %+v", m.Edges) + } + if m.Partial { + t.Error("partial, though every artist is cached") + } + // Bob can't map alice's set. + if _, err := e.svc.Map(t.Context(), e.bob, discover.MapQuery{SetID: set.ID}); err == nil { + t.Error("bob mapped alice's set") + } +} + +func TestMapOfAnArtistIsBounded(t *testing.T) { + e := newEnv(t) + // A big neighborhood: Center has 80 neighbors, each with 10 of its own. + var sims []any + for i := range 80 { + name := fmt.Sprintf("Near %d", i) + sims = append(sims, name, 0.9-float64(i)*0.01) + var far []any + for j := range 10 { + far = append(far, fmt.Sprintf("Far %d-%d", i, j), 0.8-float64(j)*0.05) + } + e.graph.artist(name, 1000, far...) + } + e.graph.artist("Center", 5_000_000, sims...) + start := time.Now() + m, err := e.svc.Map(t.Context(), e.alice, discover.MapQuery{Artist: " Center "}) + if err != nil { + t.Fatal(err) + } + if d := time.Since(start); d > 2*time.Second { + t.Errorf("took %s", d) + } + if len(m.Nodes) != discover.DefaultMapLimit { + t.Fatalf("%d nodes", len(m.Nodes)) + } + if c := m.Nodes[0]; c.Name != "Center" || !c.Center || c.Seed { + t.Errorf("center: %+v", c) + } + hops := map[int]int{} + for _, n := range m.Nodes[1:] { + hops[n.Hop]++ + } + if hops[1] == 0 || hops[2] == 0 { + t.Errorf("hops: %v", hops) + } + // Two steps out, from the nearest neighbors: their own nearest. + if !edge(m, "Near 0", "Far 0-0") { + t.Error("no edge to a neighbor's neighbor") + } + // Far artists aren't cached: they're warmed, and the map says so. + if !m.Partial || !slices.Contains(e.graph.warmed, "Far 0-0") { + t.Errorf("partial %v, warmed %v", m.Partial, e.graph.warmed) + } + // Only the center is fetched, never the neighbors. + if len(e.graph.fetched) != 0 { + t.Errorf("fetched %v", e.graph.fetched) + } + + one, err := e.svc.Map(t.Context(), e.alice, discover.MapQuery{Artist: "Center", Depth: 1, Limit: 20}) + if err != nil { + t.Fatal(err) + } + if len(one.Nodes) != 20 || slices.ContainsFunc(one.Nodes, func(n discover.MapNode) bool { return n.Hop > 1 }) { + t.Errorf("depth 1: %d nodes", len(one.Nodes)) + } +} + +func TestMapOfAnUnknownArtist(t *testing.T) { + e := newEnv(t) + m, err := e.svc.Map(t.Context(), e.alice, discover.MapQuery{Artist: "Nobody Knows"}) + if err != nil { + t.Fatal(err) + } + if len(m.Nodes) != 1 || m.Nodes[0].Known || !m.Nodes[0].Center { + t.Errorf("nodes: %+v", m.Nodes) + } + if !slices.Contains(e.graph.fetched, "Nobody Knows") { + t.Errorf("not fetched: %v", e.graph.fetched) + } + for _, q := range []discover.MapQuery{{}, {Artist: "x", SetID: "y"}, {Artist: "x", Depth: 3}, {Artist: "x", Limit: 500}} { + if _, err := e.svc.Map(t.Context(), e.alice, q); !isInvalid(err) { + t.Errorf("%+v: %v", q, err) + } + } +} + +func TestArtistSongs(t *testing.T) { + e := newEnv(t) + credit := []provider.ArtistCredit{{Name: "Hum"}} + e.svc.Library = owned{library.Holding(provider.Track{Title: "Hum song 1", Artists: credit})} + if err := e.svc.Thumb(t.Context(), e.alice, "", discover.ThumbDown, discover.Song{Title: "Hum song 2", Artist: "Hum"}); err != nil { + t.Fatal(err) + } + got, err := e.svc.ArtistSongs(t.Context(), e.alice, "hum", "") + if err != nil { + t.Fatal(err) + } + var titles []string + for _, it := range got.Items { + titles = append(titles, it.Title) + } + if !slices.Equal(titles, []string{"Hum song 3", "Hum song 4", "Hum song 5"}) { + t.Errorf("songs: %v", titles) + } + if got.Items[0].DeezerID == 0 { + t.Error("no Deezer ID") + } + if _, err := e.svc.Block(t.Context(), e.alice, "Hum"); err != nil { + t.Fatal(err) + } + if got, _ := e.svc.ArtistSongs(t.Context(), e.alice, "Hum", ""); len(got.Items) != 0 { + t.Errorf("a blocked artist's songs: %+v", got.Items) + } +} diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go index f0aa92a..b75712c 100644 --- a/server/internal/httpapi/api.gen.go +++ b/server/internal/httpapi/api.gen.go @@ -2164,6 +2164,73 @@ type DiscoveryLibrary struct { Songs int `json:"songs"` } +// DiscoveryMap defines model for DiscoveryMap. +type DiscoveryMap struct { + Edges []DiscoveryMapEdge `json:"edges"` + + // Library How far your library (your own Navidrome links) is indexed. + Library DiscoveryLibrary `json:"library"` + NoGraph bool `json:"noGraph"` + + // Nodes The artist or seeds first, then the rest, nearest first. + Nodes []DiscoveryMapNode `json:"nodes"` + + // Partial Some artists aren't known yet and are being looked up; load the map again shortly for more. + Partial bool `json:"partial"` + Set *DiscoverySet `json:"set,omitempty"` +} + +// DiscoveryMapEdge defines model for DiscoveryMapEdge. +type DiscoveryMapEdge struct { + From string `json:"from"` + + // Score How alike, 0 to 1. + Score float32 `json:"score"` + Sources []string `json:"sources"` + To string `json:"to"` +} + +// DiscoveryMapNode An artist on a map. +type DiscoveryMapNode struct { + // Blocked You said they aren't for you. Only ever the artist the map is of, or a seed. + Blocked bool `json:"blocked"` + + // Center The artist the map is of. + Center bool `json:"center"` + + // Fans Fans on Deezer, a measure of how well known. + Fans *int64 `json:"fans,omitempty"` + + // Hop Steps from the artist or seeds; 0 for those. + Hop int `json:"hop"` + + // Key How the artist is compared, and what edges name. + Key string `json:"key"` + + // Known The music graph knows them yet. One it doesn't is being looked up. + Known bool `json:"known"` + Mbid *string `json:"mbid,omitempty"` + Name string `json:"name"` + + // Owned You have something of theirs on your own Navidrome. + Owned bool `json:"owned"` + + // Picture Their picture's image ID, for getDiscoveryImage (kind `artist`). + Picture *string `json:"picture,omitempty"` + + // Score How near, 0 to 1; 1 for the artist or seeds. + Score float32 `json:"score"` + + // Seed One of the set's seeds. + Seed bool `json:"seed"` + + // Seeds How many of the set's seeds they're near. + Seeds int `json:"seeds"` + + // Tags Their strongest tags. + Tags []string `json:"tags"` +} + // DiscoveryMode Which feeds a set shows: `deep` cuts (anything new to you), `fresh` // releases (coming), or `both`. type DiscoveryMode string @@ -2218,6 +2285,15 @@ type DiscoverySetSummary struct { UpdatedAt time.Time `json:"updatedAt"` } +// DiscoverySongs defines model for DiscoverySongs. +type DiscoverySongs struct { + Items []DiscoveryItem `json:"items"` + + // Library How far your library (your own Navidrome links) is indexed. + Library DiscoveryLibrary `json:"library"` + NoGraph bool `json:"noGraph"` +} + // Display defines model for Display. type Display struct { // Audio It may play the room's audio, as the speaker. @@ -4727,6 +4803,24 @@ type GetDiscoveryImageParams struct { Size *int `form:"size,omitempty" json:"size,omitempty"` } +// GetDiscoveryMapParams defines parameters for GetDiscoveryMap. +type GetDiscoveryMapParams struct { + // Artist The artist's name. Give this or `setId`. + Artist *string `form:"artist,omitempty" json:"artist,omitempty"` + + // Mbid The artist's MusicBrainz ID, when known. + Mbid *string `form:"mbid,omitempty" json:"mbid,omitempty"` + + // SetId One of your sets. Give this or `artist`. + SetId *string `form:"setId,omitempty" json:"setId,omitempty"` + + // Depth Steps out from the artist or seeds. Default 2 around an artist, 1 around a set. + Depth *int `form:"depth,omitempty" json:"depth,omitempty"` + + // Limit How many artists, at most. Default 60. + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` +} + // GetDiscoveryPreviewParams defines parameters for GetDiscoveryPreview. type GetDiscoveryPreviewParams struct { Range *string `json:"Range,omitempty"` @@ -4741,6 +4835,12 @@ type GetDiscoveryFeedParams struct { Refresh *bool `form:"refresh,omitempty" json:"refresh,omitempty"` } +// ListDiscoveryArtistSongsParams defines parameters for ListDiscoveryArtistSongs. +type ListDiscoveryArtistSongsParams struct { + Artist string `form:"artist" json:"artist"` + Mbid *string `form:"mbid,omitempty" json:"mbid,omitempty"` +} + // RenamePasskeyJSONBody defines parameters for RenamePasskey. type RenamePasskeyJSONBody struct { Name string `json:"name"` @@ -5216,6 +5316,9 @@ type ServerInterface interface { // RefreshDiscoveryLibrary Index your library again now // (POST /me/discover/library) RefreshDiscoveryLibrary(w http.ResponseWriter, r *http.Request) + // GetDiscoveryMap A map of artists around an artist or a set + // (GET /me/discover/map) + GetDiscoveryMap(w http.ResponseWriter, r *http.Request, params GetDiscoveryMapParams) // GetDiscoveryPreview A song's 30-second preview // (GET /me/discover/preview/{deezerTrackId}) GetDiscoveryPreview(w http.ResponseWriter, r *http.Request, deezerTrackId int64, params GetDiscoveryPreviewParams) @@ -5243,6 +5346,9 @@ type ServerInterface interface { // GetDiscoveryFeed A page of a set's feed // (GET /me/discover/sets/{setId}/feed) GetDiscoveryFeed(w http.ResponseWriter, r *http.Request, setId DiscoverySetId, params GetDiscoveryFeedParams) + // ListDiscoveryArtistSongs An artist's songs you don't have + // (GET /me/discover/songs) + ListDiscoveryArtistSongs(w http.ResponseWriter, r *http.Request, params ListDiscoveryArtistSongsParams) // ListPasskeys Your passkeys // (GET /me/passkeys) ListPasskeys(w http.ResponseWriter, r *http.Request) @@ -7086,6 +7192,91 @@ func (siw *ServerInterfaceWrapper) RefreshDiscoveryLibrary(w http.ResponseWriter handler.ServeHTTP(w, r) } +// GetDiscoveryMap operation middleware +func (siw *ServerInterfaceWrapper) GetDiscoveryMap(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params GetDiscoveryMapParams + + // ------------- 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 + } + + // ------------- Optional query parameter "mbid" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "mbid", r.URL.Query(), ¶ms.Mbid, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "mbid"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "mbid", Err: err}) + } + return + } + + // ------------- Optional query parameter "setId" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "setId", r.URL.Query(), ¶ms.SetId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "setId"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "setId", Err: err}) + } + return + } + + // ------------- Optional query parameter "depth" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "depth", r.URL.Query(), ¶ms.Depth, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "depth"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "depth", Err: err}) + } + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetDiscoveryMap(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetDiscoveryPreview operation middleware func (siw *ServerInterfaceWrapper) GetDiscoveryPreview(w http.ResponseWriter, r *http.Request) { @@ -7358,6 +7549,52 @@ func (siw *ServerInterfaceWrapper) GetDiscoveryFeed(w http.ResponseWriter, r *ht handler.ServeHTTP(w, r) } +// ListDiscoveryArtistSongs operation middleware +func (siw *ServerInterfaceWrapper) ListDiscoveryArtistSongs(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ListDiscoveryArtistSongsParams + + // ------------- Required query parameter "artist" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "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 + } + + // ------------- Optional query parameter "mbid" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "mbid", r.URL.Query(), ¶ms.Mbid, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "mbid"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "mbid", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListDiscoveryArtistSongs(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListPasskeys operation middleware func (siw *ServerInterfaceWrapper) ListPasskeys(w http.ResponseWriter, r *http.Request) { @@ -10837,6 +11074,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/discover/blocks", wrapper.BlockDiscoveryArtist) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/me/discover/blocks/{artistKey}", wrapper.UnblockDiscoveryArtist) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/artists", wrapper.SearchDiscoveryArtists) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/map", wrapper.GetDiscoveryMap) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/songs", wrapper.ListDiscoveryArtistSongs) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/library", wrapper.GetDiscoveryLibrary) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/discover/library", wrapper.RefreshDiscoveryLibrary) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/preview/{deezerTrackId}", wrapper.GetDiscoveryPreview) @@ -13358,6 +13597,45 @@ func (response RefreshDiscoveryLibrarydefaultJSONResponse) VisitRefreshDiscovery return err } +type GetDiscoveryMapRequestObject struct { + Params GetDiscoveryMapParams +} + +type GetDiscoveryMapResponseObject interface { + VisitGetDiscoveryMapResponse(w http.ResponseWriter) error +} + +type GetDiscoveryMap200JSONResponse DiscoveryMap + +func (response GetDiscoveryMap200JSONResponse) VisitGetDiscoveryMapResponse(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 GetDiscoveryMapdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetDiscoveryMapdefaultJSONResponse) VisitGetDiscoveryMapResponse(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 GetDiscoveryPreviewRequestObject struct { DeezerTrackId int64 `json:"deezerTrackId"` Params GetDiscoveryPreviewParams @@ -13735,6 +14013,45 @@ func (response GetDiscoveryFeeddefaultJSONResponse) VisitGetDiscoveryFeedRespons return err } +type ListDiscoveryArtistSongsRequestObject struct { + Params ListDiscoveryArtistSongsParams +} + +type ListDiscoveryArtistSongsResponseObject interface { + VisitListDiscoveryArtistSongsResponse(w http.ResponseWriter) error +} + +type ListDiscoveryArtistSongs200JSONResponse DiscoverySongs + +func (response ListDiscoveryArtistSongs200JSONResponse) VisitListDiscoveryArtistSongsResponse(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 ListDiscoveryArtistSongsdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ListDiscoveryArtistSongsdefaultJSONResponse) VisitListDiscoveryArtistSongsResponse(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 ListPasskeysRequestObject struct { } @@ -18152,6 +18469,9 @@ type StrictServerInterface interface { // RefreshDiscoveryLibrary Index your library again now // (POST /me/discover/library) RefreshDiscoveryLibrary(ctx context.Context, request RefreshDiscoveryLibraryRequestObject) (RefreshDiscoveryLibraryResponseObject, error) + // GetDiscoveryMap A map of artists around an artist or a set + // (GET /me/discover/map) + GetDiscoveryMap(ctx context.Context, request GetDiscoveryMapRequestObject) (GetDiscoveryMapResponseObject, error) // GetDiscoveryPreview A song's 30-second preview // (GET /me/discover/preview/{deezerTrackId}) GetDiscoveryPreview(ctx context.Context, request GetDiscoveryPreviewRequestObject) (GetDiscoveryPreviewResponseObject, error) @@ -18179,6 +18499,9 @@ type StrictServerInterface interface { // GetDiscoveryFeed A page of a set's feed // (GET /me/discover/sets/{setId}/feed) GetDiscoveryFeed(ctx context.Context, request GetDiscoveryFeedRequestObject) (GetDiscoveryFeedResponseObject, error) + // ListDiscoveryArtistSongs An artist's songs you don't have + // (GET /me/discover/songs) + ListDiscoveryArtistSongs(ctx context.Context, request ListDiscoveryArtistSongsRequestObject) (ListDiscoveryArtistSongsResponseObject, error) // ListPasskeys Your passkeys // (GET /me/passkeys) ListPasskeys(ctx context.Context, request ListPasskeysRequestObject) (ListPasskeysResponseObject, error) @@ -20287,6 +20610,32 @@ func (sh *strictHandler) RefreshDiscoveryLibrary(w http.ResponseWriter, r *http. } } +// GetDiscoveryMap operation middleware +func (sh *strictHandler) GetDiscoveryMap(w http.ResponseWriter, r *http.Request, params GetDiscoveryMapParams) { + var request GetDiscoveryMapRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetDiscoveryMap(ctx, request.(GetDiscoveryMapRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetDiscoveryMap") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetDiscoveryMapResponseObject); ok { + if err := validResponse.VisitGetDiscoveryMapResponse(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)) + } +} + // GetDiscoveryPreview operation middleware func (sh *strictHandler) GetDiscoveryPreview(w http.ResponseWriter, r *http.Request, deezerTrackId int64, params GetDiscoveryPreviewParams) { var request GetDiscoveryPreviewRequestObject @@ -20541,6 +20890,32 @@ func (sh *strictHandler) GetDiscoveryFeed(w http.ResponseWriter, r *http.Request } } +// ListDiscoveryArtistSongs operation middleware +func (sh *strictHandler) ListDiscoveryArtistSongs(w http.ResponseWriter, r *http.Request, params ListDiscoveryArtistSongsParams) { + var request ListDiscoveryArtistSongsRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListDiscoveryArtistSongs(ctx, request.(ListDiscoveryArtistSongsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListDiscoveryArtistSongs") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListDiscoveryArtistSongsResponseObject); ok { + if err := validResponse.VisitListDiscoveryArtistSongsResponse(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)) + } +} + // ListPasskeys operation middleware func (sh *strictHandler) ListPasskeys(w http.ResponseWriter, r *http.Request) { var request ListPasskeysRequestObject diff --git a/server/internal/httpapi/discover.go b/server/internal/httpapi/discover.go index d31cf19..334f3cc 100644 --- a/server/internal/httpapi/discover.go +++ b/server/internal/httpapi/discover.go @@ -161,17 +161,78 @@ func (s *Server) GetDiscoveryFeed(ctx context.Context, req GetDiscoveryFeedReque if err != nil { return nil, err } - out := DiscoveryFeed{Items: make([]DiscoveryItem, len(f.Items)), Next: nonEmpty(f.Next), Library: toDiscoveryLibrary(f.Library), NoGraph: f.NoGraph} - for i, it := range f.Items { - out.Items[i] = DiscoveryItem{ + out := DiscoveryFeed{Items: toDiscoveryItems(f.Items), Next: nonEmpty(f.Next), Library: toDiscoveryLibrary(f.Library), NoGraph: f.NoGraph} + return GetDiscoveryFeed200JSONResponse(out), nil +} + +func toDiscoveryItems(items []discover.Item) []DiscoveryItem { + out := make([]DiscoveryItem, len(items)) + for i, it := range items { + out[i] = DiscoveryItem{ Key: it.Key, Title: it.Title, Artist: it.Artist, Album: nonEmpty(it.Album), Mbid: nonEmpty(it.MBID), Isrc: nonEmpty(it.ISRC), DeezerId: nonZero(it.DeezerID), Cover: nonEmpty(it.Cover), Because: it.Because, Seed: it.Seed, } - if out.Items[i].Because == nil { - out.Items[i].Because = []string{} + if out[i].Because == nil { + out[i].Because = []string{} } } - return GetDiscoveryFeed200JSONResponse(out), nil + return out +} + +// GetDiscoveryMap returns a map of artists around an artist or a set. +func (s *Server) GetDiscoveryMap(ctx context.Context, req GetDiscoveryMapRequestObject) (GetDiscoveryMapResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + p := req.Params + q := discover.MapQuery{Artist: deref(p.Artist), MBID: deref(p.Mbid), SetID: deref(p.SetId)} + if p.Depth != nil { + q.Depth = *p.Depth + } + if p.Limit != nil { + q.Limit = *p.Limit + } + m, err := s.Discover.Map(ctx, u.ID, q) + if err != nil { + return nil, err + } + out := DiscoveryMap{ + Nodes: make([]DiscoveryMapNode, len(m.Nodes)), Edges: make([]DiscoveryMapEdge, len(m.Edges)), + Partial: m.Partial, NoGraph: m.NoGraph, Library: toDiscoveryLibrary(m.Library), + } + if m.Set != nil { + out.Set = ptr(toDiscoverySet(*m.Set)) + } + for i, n := range m.Nodes { + out.Nodes[i] = DiscoveryMapNode{ + Key: n.Key, Name: n.Name, Mbid: nonEmpty(n.MBID), Fans: nonZero(n.Fans), Picture: nonEmpty(n.Picture), Tags: n.Tags, + Owned: n.Owned, Seed: n.Seed, Center: n.Center, Blocked: n.Blocked, Hop: n.Hop, Seeds: n.Seeds, + Score: float32(n.Score), Known: n.Known, + } + if out.Nodes[i].Tags == nil { + out.Nodes[i].Tags = []string{} + } + } + for i, e := range m.Edges { + out.Edges[i] = DiscoveryMapEdge{From: e.From, To: e.To, Score: float32(e.Score), Sources: e.Sources} + } + return GetDiscoveryMap200JSONResponse(out), nil +} + +// ListDiscoveryArtistSongs returns an artist's songs the caller doesn't have. +func (s *Server) ListDiscoveryArtistSongs(ctx context.Context, req ListDiscoveryArtistSongsRequestObject) (ListDiscoveryArtistSongsResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + songs, err := s.Discover.ArtistSongs(ctx, u.ID, req.Params.Artist, deref(req.Params.Mbid)) + if err != nil { + return nil, err + } + return ListDiscoveryArtistSongs200JSONResponse(DiscoverySongs{ + Items: toDiscoveryItems(songs.Items), Library: toDiscoveryLibrary(songs.Library), NoGraph: songs.NoGraph, + }), nil } // PostDiscoveryFeedback records a thumb on a song. diff --git a/server/internal/httpapi/discover_test.go b/server/internal/httpapi/discover_test.go index 653d4c9..21e62e8 100644 --- a/server/internal/httpapi/discover_test.go +++ b/server/internal/httpapi/discover_test.go @@ -135,13 +135,33 @@ func TestDiscover(t *testing.T) { var lib httpapi.DiscoveryLibrary alice.want(http.StatusOK, "GET", "/me/discover/library", nil).decode(t, &lib) + // The map, of the set and of any artist, and an artist's songs. + var m httpapi.DiscoveryMap + alice.want(http.StatusOK, "GET", "/me/discover/map?setId="+set.Id, nil).decode(t, &m) + if m.Set == nil || len(m.Nodes) != 3 || !m.Nodes[0].Seed || m.Nodes[1].Name != "Failure" || len(m.Edges) != 2 { + t.Fatalf("set map: %+v", m) + } + m = httpapi.DiscoveryMap{} + alice.want(http.StatusOK, "GET", "/me/discover/map?artist=Hum&depth=1&limit=10", nil).decode(t, &m) + if m.Set != nil || len(m.Nodes) != 3 || !m.Nodes[0].Center { + t.Fatalf("artist map: %+v", m) + } + if r := alice.do("GET", "/me/discover/map", nil); r.status != http.StatusBadRequest { + t.Errorf("a map of nothing: %d", r.status) + } + var songs httpapi.DiscoverySongs + alice.want(http.StatusOK, "GET", "/me/discover/songs?artist=Shiner", nil).decode(t, &songs) + if len(songs.Items) != 3 || songs.Items[0].DeezerId == nil { + t.Errorf("songs: %+v", songs) + } + // Bob can't see alice's set, or its feed. var bobs []httpapi.DiscoverySetSummary bob.want(http.StatusOK, "GET", "/me/discover/sets", nil).decode(t, &bobs) if len(bobs) != 0 { t.Errorf("bob's sets: %+v", bobs) } - for _, path := range []string{base, base + "/feed"} { + for _, path := range []string{base, base + "/feed", "/me/discover/map?setId=" + set.Id} { if r := bob.do("GET", path, nil); r.status != http.StatusNotFound { t.Errorf("bob GET %s: %d", path, r.status) } From fb7bc76606dee2cc43b2c79ecb564a0bbac88186 Mon Sep 17 00:00:00 2001 From: Collin Pendleton <collinp@collinpendleton.com> Date: Tue, 13 Oct 2026 10:31:55 -0500 Subject: [PATCH 2/2] Personal discovery: the Map view (MAD-821) A Map tab beside a set's feed, and a map of any artist from Discover's search. It's laid out with d3-force, where alike artists sit close and well-known ones are bigger. Artists you have are dimmed and new ones are in full color. Seeds and the center are ringed, and artists near several seeds get a soft halo. Tap one to hear their songs (preview and YouTube), add them to a set, say they're not for you, or re-center on them. Each re-center is a step in the URL, so Back and the breadcrumbs retrace them. A new layout glides from the last one, and it stays calm, with nothing beat-reactive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 2 +- web/package.json | 2 + web/pnpm-lock.yaml | 39 +++ web/src/api/schema.gen.ts | 159 ++++++++++++ web/src/components/discover/add-to-set.tsx | 2 +- web/src/components/discover/artist-search.tsx | 13 +- .../components/discover/map-artist-sheet.tsx | 143 +++++++++++ .../components/discover/map-graph.test.tsx | 48 ++++ web/src/components/discover/map-graph.tsx | 177 +++++++++++++ web/src/components/discover/set-tabs.tsx | 34 +++ web/src/lib/discover-map.test.ts | 71 ++++++ web/src/lib/discover-map.ts | 132 ++++++++++ web/src/lib/discover.ts | 24 ++ web/src/routeTree.gen.ts | 21 ++ .../routes/_app/_authed/discover.$setId.tsx | 2 + .../routes/_app/_authed/discover.index.tsx | 21 +- web/src/routes/_app/_authed/discover.map.tsx | 241 ++++++++++++++++++ 17 files changed, 1124 insertions(+), 7 deletions(-) create mode 100644 web/src/components/discover/map-artist-sheet.tsx create mode 100644 web/src/components/discover/map-graph.test.tsx create mode 100644 web/src/components/discover/map-graph.tsx create mode 100644 web/src/components/discover/set-tabs.tsx create mode 100644 web/src/lib/discover-map.test.ts create mode 100644 web/src/lib/discover-map.ts create mode 100644 web/src/routes/_app/_authed/discover.map.tsx diff --git a/README.md b/README.md index 19dbebd..1583056 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Images are published to `ghcr.io/madeofpendletonwool/syncphony`: `:main` tracks ## Finding music you don't have -The **Discover** tab is yours alone, outside any room. Make a set of artists you love ("Moody post-rock", "90s alt"), and it fills with songs near them that aren't on your own Navidrome, each saying which of your artists it's near. Play a 30-second preview, open the whole song on YouTube, give it a thumb, or say an artist isn't for you. Your library is only used to skip what you have, never to guess your taste ([ADR 0017](docs/adr/0017-personal-discovery.md)). +The **Discover** tab is yours alone, outside any room. Make a set of artists you love ("Moody post-rock", "90s alt"), and it fills with songs near them that aren't on your own Navidrome, each saying which of your artists it's near. Play a 30-second preview, open the whole song on YouTube, give it a thumb, or say an artist isn't for you. Your library is only used to skip what you have, never to guess your taste ([ADR 0017](docs/adr/0017-personal-discovery.md)). Its **Map** draws the artists around a set, or around any artist you search for, the way music-map.com does: the ones you have are dimmed, so the new names stand out. Tap one to hear their songs, add them to a set, or re-center on them and keep wandering. What works where (and what doesn't yet) is in [docs/player-mode.md](docs/player-mode.md). diff --git a/web/package.json b/web/package.json index 1475d9a..f6418a0 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@tanstack/react-router": "^1.170.41", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", + "d3-force": "^3.0.0", "lucide-react": "^1.52.0", "motion": "^14.0.0", "openapi-fetch": "^0.17.0", @@ -32,6 +33,7 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/router-plugin": "^1.168.42", "@testing-library/react": "^16.3.3", + "@types/d3-force": "^3.0.10", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 56409d3..49e1e62 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: cn: specifier: ^0.4.0 version: 0.4.0 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 lucide-react: specifier: ^1.52.0 version: 1.52.0(@types/react@19.3.0)(react@19.3.0) @@ -60,6 +63,9 @@ importers: '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/node': specifier: ^24.13.3 version: 24.19.1 @@ -1477,6 +1483,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1757,6 +1766,22 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-urls@8.0.0: resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} engines: {node: ^22.14.0 || >=24.0.0} @@ -4724,6 +4749,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-force@3.0.10': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -4960,6 +4987,18 @@ snapshots: csstype@3.2.3: {} + d3-dispatch@3.0.1: {} + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-quadtree@3.0.1: {} + + d3-timer@3.0.1: {} + data-urls@8.0.0: dependencies: whatwg-mimetype: 5.0.0 diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index ecd028e..9713bcc 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -2413,6 +2413,55 @@ export interface paths { patch?: never; trace?: never; }; + "/me/discover/map": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * A map of artists around an artist or a set + * @description A graph in the spirit of music-map.com: give `artist` (any artist, + * not just yours) or `setId`. Around an artist, its neighbors and, + * at depth 2, their nearest neighbors. Around a set, its seeds and + * their neighbors, those near the most seeds first. Edges say how + * alike two artists are. Artists you blocked are left out, unless + * the map is of them. It reads the music graph's cache: artists not + * known yet are looked up in the background (`partial`), and loading + * the map again shows more. + */ + get: operations["getDiscoveryMap"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/discover/songs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * An artist's songs you don't have + * @description Their best-known songs, without the ones you have, turned down or + * blocked: for an artist on the map. Each has a preview when Deezer + * has one. + */ + get: operations["listDiscoveryArtistSongs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/me/discover/library": { parameters: { query?: never; @@ -5618,6 +5667,60 @@ export interface components { /** @description The server knows nothing of how music relates (no sources are on), so feeds are empty. */ noGraph: boolean; }; + /** @description An artist on a map. */ + DiscoveryMapNode: { + /** @description How the artist is compared, and what edges name. */ + key: string; + name: string; + mbid?: string; + /** + * Format: int64 + * @description Fans on Deezer, a measure of how well known. + */ + fans?: number; + /** @description Their picture's image ID, for getDiscoveryImage (kind `artist`). */ + picture?: string; + /** @description Their strongest tags. */ + tags: string[]; + /** @description You have something of theirs on your own Navidrome. */ + owned: boolean; + /** @description One of the set's seeds. */ + seed: boolean; + /** @description The artist the map is of. */ + center: boolean; + /** @description You said they aren't for you. Only ever the artist the map is of, or a seed. */ + blocked: boolean; + /** @description Steps from the artist or seeds; 0 for those. */ + hop: number; + /** @description How many of the set's seeds they're near. */ + seeds: number; + /** @description How near, 0 to 1; 1 for the artist or seeds. */ + score: number; + /** @description The music graph knows them yet. One it doesn't is being looked up. */ + known: boolean; + }; + DiscoveryMapEdge: { + from: string; + to: string; + /** @description How alike, 0 to 1. */ + score: number; + sources: string[]; + }; + DiscoveryMap: { + set?: components["schemas"]["DiscoverySet"]; + /** @description The artist or seeds first, then the rest, nearest first. */ + nodes: components["schemas"]["DiscoveryMapNode"][]; + edges: components["schemas"]["DiscoveryMapEdge"][]; + /** @description Some artists aren't known yet and are being looked up; load the map again shortly for more. */ + partial: boolean; + noGraph: boolean; + library: components["schemas"]["DiscoveryLibrary"]; + }; + DiscoverySongs: { + items: components["schemas"]["DiscoveryItem"][]; + library: components["schemas"]["DiscoveryLibrary"]; + noGraph: boolean; + }; DiscoveryFeedbackRequest: { /** @enum {string} */ kind: "up" | "down"; @@ -8870,6 +8973,62 @@ export interface operations { default: components["responses"]["Error"]; }; }; + getDiscoveryMap: { + parameters: { + query?: { + /** @description The artist's name. Give this or `setId`. */ + artist?: string; + /** @description The artist's MusicBrainz ID, when known. */ + mbid?: string; + /** @description One of your sets. Give this or `artist`. */ + setId?: string; + /** @description Steps out from the artist or seeds. Default 2 around an artist, 1 around a set. */ + depth?: number; + /** @description How many artists, at most. Default 60. */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The map */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoveryMap"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + listDiscoveryArtistSongs: { + parameters: { + query: { + artist: string; + mbid?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The songs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoverySongs"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; getDiscoveryLibrary: { parameters: { query?: never; diff --git a/web/src/components/discover/add-to-set.tsx b/web/src/components/discover/add-to-set.tsx index 509c516..846f08e 100644 --- a/web/src/components/discover/add-to-set.tsx +++ b/web/src/components/discover/add-to-set.tsx @@ -24,7 +24,7 @@ export function AddToSetButton({ artist }: { artist: string }) { ) } -function AddToSetSheet({ artist, open, onOpenChange }: { artist: string; open: boolean; onOpenChange: (open: boolean) => void }) { +export function AddToSetSheet({ artist, open, onOpenChange }: { artist: string; open: boolean; onOpenChange: (open: boolean) => void }) { const qc = useQueryClient() const sets = useQuery({ ...setsQuery, enabled: open }) const [name, setName] = useState('') diff --git a/web/src/components/discover/artist-search.tsx b/web/src/components/discover/artist-search.tsx index 98ef246..496ce82 100644 --- a/web/src/components/discover/artist-search.tsx +++ b/web/src/components/discover/artist-search.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { LoaderCircle, Plus, Search } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { Artwork } from '@/components/artwork' import { Input } from '@/components/ui/input' import { artistSearchQuery, fansText, imageUrl } from '@/lib/discover' @@ -15,11 +15,16 @@ export function ArtistSearch({ have, autoFocus, className, + placeholder = 'Add an artist', + icon = <Plus className="size-4" />, }: { onPick: (name: string) => void have: ReadonlySet<string> autoFocus?: boolean className?: string + placeholder?: string + /** What picking an artist does, shown beside each. */ + icon?: ReactNode }) { const [text, setText] = useState('') const [q, setQ] = useState('') @@ -50,8 +55,8 @@ export function ArtistSearch({ autoFocus={autoFocus} value={text} onChange={(e) => setText(e.target.value)} - placeholder="Add an artist" - aria-label="Find an artist to add" + placeholder={placeholder} + aria-label={placeholder} className="pl-10" maxLength={200} /> @@ -74,7 +79,7 @@ export function ArtistSearch({ <span className="block truncate font-medium">{a.name}</span> {a.fans ? <span className="text-caption text-muted-foreground">{fansText(a.fans)}</span> : null} </span> - <span className="text-caption text-muted-foreground">{added ? 'Added' : <Plus className="size-4" />}</span> + <span className="text-caption text-muted-foreground">{added ? 'Added' : icon}</span> </button> </li> ) diff --git a/web/src/components/discover/map-artist-sheet.tsx b/web/src/components/discover/map-artist-sheet.tsx new file mode 100644 index 0000000..01e7e0b --- /dev/null +++ b/web/src/components/discover/map-artist-sheet.tsx @@ -0,0 +1,143 @@ +import { useQuery } from '@tanstack/react-query' +import { Crosshair, ExternalLink, LoaderCircle, Pause, Play, Plus, UserX } from 'lucide-react' +import { errorMessage } from '@/api/errors' +import { Artwork } from '@/components/artwork' +import { Notice } from '@/components/notice' +import { Sheet } from '@/components/sheet' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { artistSongsQuery, fansText, imageUrl, togglePreview, usePreview, youtubeUrl, type DiscoveryItem, type DiscoveryMapNode } from '@/lib/discover' +import { cn } from '@/lib/utils' + +/** + * An artist on the map: their best-known songs you don't have, each with + * a preview and a YouTube link, and what to do next: re-center the map on + * them, add them to a set, or say they aren't for you. + */ +export function MapArtistSheet({ + node, + onOpenChange, + onRecenter, + onAddToSet, + addTo, + onBlock, +}: { + node: DiscoveryMapNode | undefined + onOpenChange: (open: boolean) => void + onRecenter: (n: DiscoveryMapNode) => void + /** Opens the list of sets to add them to. */ + onAddToSet: (n: DiscoveryMapNode) => void + /** The set the map is of, to add them to it in one tap. */ + addTo?: { name: string; add: (n: DiscoveryMapNode) => void; pending: boolean } + onBlock: (n: DiscoveryMapNode) => void +}) { + const facts = node + ? [node.owned ? 'In your library' : 'New to you', fansText(node.fans), ...node.tags.slice(0, 2)].filter(Boolean).join(' · ') + : '' + return ( + <Sheet open={!!node} onOpenChange={onOpenChange} title={node?.name ?? ''} description={facts}> + {node && ( + <> + <div className="flex items-center gap-3"> + <Artwork src={imageUrl('artist', node.picture, 250)} className="size-16 rounded-full shadow-none" /> + <div className="flex min-w-0 flex-1 flex-wrap gap-2"> + {!node.center && ( + <Button size="sm" onClick={() => onRecenter(node)}> + <Crosshair data-icon="inline-start" /> + Re-center here + </Button> + )} + {addTo && !node.seed ? ( + <Button size="sm" variant="glass" disabled={addTo.pending} onClick={() => addTo.add(node)}> + {addTo.pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Plus data-icon="inline-start" />} + Add to {addTo.name} + </Button> + ) : null} + <Button size="sm" variant="glass" onClick={() => onAddToSet(node)}> + <Plus data-icon="inline-start" /> + {addTo ? 'Other set' : 'Add to set'} + </Button> + {!node.seed && ( + <Button size="sm" variant="ghost" onClick={() => onBlock(node)}> + <UserX data-icon="inline-start" /> + Not for me + </Button> + )} + </div> + </div> + <Songs artist={node.name} /> + </> + )} + </Sheet> + ) +} + +function Songs({ artist }: { artist: string }) { + const songs = useQuery(artistSongsQuery(artist)) + if (songs.isPending) { + return ( + <div className="flex flex-col gap-2"> + {[0, 1, 2].map((i) => ( + <Skeleton key={i} className="h-14 rounded-2xl" /> + ))} + </div> + ) + } + if (songs.isError) return <Notice>{errorMessage(songs.error)}</Notice> + if (songs.data.items.length === 0) { + return ( + <p className="text-sm text-muted-foreground"> + {songs.data.noGraph ? "This server doesn't know anyone's songs yet." : `Nothing of ${artist}'s you don't have turned up yet.`} + </p> + ) + } + return ( + <ul className="-mx-2 flex flex-col" aria-label={`Songs by ${artist}`}> + {songs.data.items.map((it) => ( + <SongRow key={it.key} item={it} /> + ))} + </ul> + ) +} + +function SongRow({ item }: { item: DiscoveryItem }) { + const p = usePreview(item.key) + const canPlay = !!item.deezerId && !p.failed + return ( + <li className="flex items-center gap-3 rounded-2xl p-2"> + <button + type="button" + disabled={!canPlay} + onClick={() => togglePreview(item)} + aria-label={!canPlay ? `No preview of ${item.title}` : p.playing ? `Pause ${item.title}` : `Play a preview of ${item.title}`} + className="group relative shrink-0 rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-default" + > + <Artwork src={imageUrl('cover', item.cover, 120)} className="size-11 rounded-lg shadow-none" /> + {canPlay && ( + <span + className={cn( + 'absolute inset-0 grid place-items-center rounded-lg bg-black/35 text-white transition-opacity', + p.playing || p.loading ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 [@media(hover:none)]:opacity-100', + )} + > + {p.loading ? <LoaderCircle className="size-4 animate-spin" /> : p.playing ? <Pause className="size-4 fill-current" /> : <Play className="size-4 fill-current" />} + </span> + )} + {(p.playing || p.progress > 0) && ( + <span className="absolute inset-x-1 bottom-1 h-0.5 overflow-hidden rounded-full bg-white/30"> + <span className="block h-full bg-white" style={{ width: `${Math.round(p.progress * 100)}%` }} /> + </span> + )} + </button> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm font-medium">{item.title}</p> + <p className="truncate text-caption text-muted-foreground">{item.album ?? (canPlay ? '' : 'No preview')}</p> + </div> + <Button asChild variant="ghost" size="icon-sm"> + <a href={youtubeUrl(item)} target="_blank" rel="noopener noreferrer" aria-label={`Open ${item.title} on YouTube`} title="Open on YouTube"> + <ExternalLink /> + </a> + </Button> + </li> + ) +} diff --git a/web/src/components/discover/map-graph.test.tsx b/web/src/components/discover/map-graph.test.tsx new file mode 100644 index 0000000..26f982a --- /dev/null +++ b/web/src/components/discover/map-graph.test.tsx @@ -0,0 +1,48 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DiscoveryMap, DiscoveryMapNode } from '@/lib/discover' +import { MapGraph } from './map-graph' + +const node = (name: string, more: Partial<DiscoveryMapNode> = {}): DiscoveryMapNode => ({ + key: name.toLowerCase(), + name, + tags: [], + owned: false, + seed: false, + center: false, + blocked: false, + hop: 1, + seeds: 1, + score: 0.5, + known: true, + ...more, +}) + +const map = (...nodes: DiscoveryMapNode[]): DiscoveryMap => ({ + nodes, + edges: nodes.slice(1).map((n) => ({ from: nodes[0].key, to: n.key, score: 0.6, sources: [] })), + partial: false, + noGraph: false, + library: { links: 0, songs: 0, building: false }, +}) + +afterEach(cleanup) + +describe('MapGraph', () => { + it('draws each artist, saying which you have, and picks one', () => { + const onSelect = vi.fn() + render(<MapGraph map={map(node('Duster', { center: true, hop: 0 }), node('Codeine', { owned: true }), node('Bedhead'))} onSelect={onSelect} />) + expect(screen.getByRole('button', { name: 'Codeine, in your library' })).toBeTruthy() + const bedhead = screen.getByRole('button', { name: 'Bedhead, new to you' }) + fireEvent.click(bedhead) + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ name: 'Bedhead' })) + fireEvent.keyDown(screen.getByRole('button', { name: 'Duster, new to you' }), { key: 'Enter' }) + expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'Duster' })) + }) + + it('lays out a new map from the last', () => { + const { rerender } = render(<MapGraph map={map(node('Duster', { center: true }), node('Low'))} onSelect={() => {}} />) + rerender(<MapGraph map={map(node('Low', { center: true }), node('Duster'), node('Codeine'))} onSelect={() => {}} />) + expect(screen.getAllByRole('button')).toHaveLength(3) + }) +}) diff --git a/web/src/components/discover/map-graph.tsx b/web/src/components/discover/map-graph.tsx new file mode 100644 index 0000000..145de61 --- /dev/null +++ b/web/src/components/discover/map-graph.tsx @@ -0,0 +1,177 @@ +import { useReducedMotion } from 'motion/react' +import { useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { DiscoveryMap, DiscoveryMapNode } from '@/lib/discover' +import { between, betweenBox, layoutMap, nodeRadius, type Box, type Layout } from '@/lib/discover-map' +import { cn } from '@/lib/utils' + +const tweenMs = 650 + +/** + * The discovery map, drawn: artists as circles, alike ones close and + * linked, bigger for better known. Ones you have are dimmed, new ones in + * full color, seeds and the artist it's of ringed, and in a set's map the + * ones near several seeds softly haloed. A changed map glides to its new + * layout. Calm on purpose: no beat-reactive effects in the app shell. + */ +export function MapGraph({ + map, + selected, + onSelect, + className, +}: { + map: DiscoveryMap + selected?: string + onSelect: (n: DiscoveryMapNode) => void + className?: string +}) { + const reduced = useReducedMotion() + const svg = useRef<SVGSVGElement>(null) + // A new map is laid out from where the last one left its artists. + const [laid, setLaid] = useState<{ map: DiscoveryMap; layout: Layout; from?: Layout }>(() => ({ map, layout: layoutMap(map) })) + if (laid.map !== map) setLaid({ map, layout: layoutMap(map, laid.layout.at), from: laid.layout }) + const { layout, from } = laid + const setMode = !!map.set + + // Tween from the last layout to this one, writing straight to the DOM + // so sixty artists move without sixty re-renders a frame. + useLayoutEffect(() => { + const el = svg.current + if (!el || !from || reduced) return + const nodes = [...el.querySelectorAll<SVGGElement>('[data-node]')] + const edges = [...el.querySelectorAll<SVGLineElement>('[data-edge]')] + const draw = (t: number) => { + const e = 1 - Math.pow(1 - t, 3) + const box = betweenBox(from.box, layout.box, e) + el.setAttribute('viewBox', viewBox(box)) + const at = (k: string) => between(from.at.get(k) ?? layout.at.get(k), layout.at.get(k) ?? { x: 0, y: 0 }, e) + for (const n of nodes) { + const p = at(n.dataset.node!) + n.setAttribute('transform', `translate(${p.x} ${p.y})`) + if (!from.at.has(n.dataset.node!)) n.style.opacity = String(e) + } + for (const l of edges) { + const [a, b] = l.dataset.edge!.split('\u0000') + const pa = at(a) + const pb = at(b) + l.setAttribute('x1', String(pa.x)) + l.setAttribute('y1', String(pa.y)) + l.setAttribute('x2', String(pb.x)) + l.setAttribute('y2', String(pb.y)) + } + } + let frame = 0 + const start = performance.now() + const step = (now: number) => { + const t = Math.min((now - start) / tweenMs, 1) + draw(t) + if (t < 1) frame = requestAnimationFrame(step) + else for (const n of nodes) n.style.opacity = '' + } + draw(0) + frame = requestAnimationFrame(step) + return () => cancelAnimationFrame(frame) + }, [layout, from, reduced]) + + const near = useMemo(() => { + const out = new Set<string>() + if (!selected) return out + for (const e of map.edges) { + if (e.from === selected) out.add(e.to) + if (e.to === selected) out.add(e.from) + } + return out + }, [map.edges, selected]) + + const pos = (k: string) => layout.at.get(k) ?? { x: 0, y: 0 } + const key = (e: KeyboardEvent, n: DiscoveryMapNode) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect(n) + } + } + + return ( + <svg + ref={svg} + viewBox={viewBox(layout.box)} + preserveAspectRatio="xMidYMid meet" + role="group" + aria-label="Map of artists" + className={cn('block h-full w-full touch-manipulation select-none', className)} + > + <g className="text-muted-foreground"> + {map.edges.map((e) => { + const a = pos(e.from) + const b = pos(e.to) + const lit = selected && (e.from === selected || e.to === selected) + return ( + <line + key={`${e.from}\u0000${e.to}`} + data-edge={`${e.from}\u0000${e.to}`} + x1={a.x} + y1={a.y} + x2={b.x} + y2={b.y} + stroke={lit ? 'var(--primary)' : 'currentColor'} + strokeOpacity={lit ? 0.7 : selected ? 0.08 : 0.12 + 0.3 * e.score} + strokeWidth={0.5 + 1.5 * e.score} + strokeLinecap="round" + /> + ) + })} + </g> + {map.nodes.map((n) => { + const p = pos(n.key) + const r = nodeRadius(n) + const dim = selected && selected !== n.key && !near.has(n.key) + const bridging = setMode && !n.seed && n.seeds >= 2 + return ( + <g + key={n.key} + data-node={n.key} + transform={`translate(${p.x} ${p.y})`} + role="button" + tabIndex={0} + aria-label={`${n.name}${n.owned ? ', in your library' : ', new to you'}${n.seed ? ', a seed' : ''}`} + aria-pressed={selected === n.key} + onClick={() => onSelect(n)} + onKeyDown={(e) => key(e, n)} + className="cursor-pointer outline-none [&:focus-visible>circle:first-of-type]:stroke-ring" + style={{ transition: 'opacity 200ms', opacity: dim ? 0.35 : 1 }} + > + {/* A bigger target than the circle, for fingers. */} + <circle r={Math.max(r + 6, 18)} fill="transparent" stroke="transparent" strokeWidth={3} /> + {bridging && <circle r={r + 9} fill="var(--primary)" fillOpacity={0.12} />} + {(n.seed || n.center) && <circle r={r + 4} fill="none" stroke="var(--primary)" strokeWidth={2} strokeOpacity={0.9} />} + <circle + r={r} + fill={n.owned ? 'var(--muted-foreground)' : 'var(--primary)'} + fillOpacity={n.owned ? 0.3 : n.known ? 0.95 : 0.55} + stroke={selected === n.key ? 'var(--foreground)' : 'var(--background)'} + strokeWidth={selected === n.key ? 2 : 1.5} + strokeDasharray={n.known ? undefined : '3 2'} + /> + <text + y={r + 12} + textAnchor="middle" + fontSize={n.center ? 13 : 11} + fontWeight={n.center || n.seed ? 600 : 500} + fill={n.owned ? 'var(--muted-foreground)' : 'var(--foreground)'} + stroke="var(--background)" + strokeWidth={3} + strokeOpacity={0.8} + paintOrder="stroke" + strokeLinejoin="round" + > + {n.name.length > 22 ? `${n.name.slice(0, 21)}…` : n.name} + </text> + </g> + ) + })} + </svg> + ) +} + +function viewBox(b: Box) { + return `${b.x} ${b.y} ${b.w} ${b.h}` +} diff --git a/web/src/components/discover/set-tabs.tsx b/web/src/components/discover/set-tabs.tsx new file mode 100644 index 0000000..dc08f0a --- /dev/null +++ b/web/src/components/discover/set-tabs.tsx @@ -0,0 +1,34 @@ +import { Link } from '@tanstack/react-router' +import { ListMusic, Waypoints } from 'lucide-react' +import { cn } from '@/lib/utils' + +const tab = + 'flex items-center gap-1.5 rounded-full px-4 py-1.5 text-sm font-medium transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50' + +/** A set's two views: its feed of songs, and the map of its artists. */ +export function SetTabs({ setId, on, className }: { setId: string; on: 'feed' | 'map'; className?: string }) { + return ( + <nav aria-label="Views" className={cn('glass inline-flex gap-1 rounded-full p-1', className)}> + <Link + to="/discover/$setId" + params={{ setId }} + replace + aria-current={on === 'feed' ? 'page' : undefined} + className={cn(tab, on === 'feed' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground')} + > + <ListMusic className="size-4" /> + Feed + </Link> + <Link + to="/discover/map" + search={{ set: setId }} + replace + aria-current={on === 'map' ? 'page' : undefined} + className={cn(tab, on === 'map' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground')} + > + <Waypoints className="size-4" /> + Map + </Link> + </nav> + ) +} diff --git a/web/src/lib/discover-map.test.ts b/web/src/lib/discover-map.test.ts new file mode 100644 index 0000000..0f51c50 --- /dev/null +++ b/web/src/lib/discover-map.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { DiscoveryMapNode } from './discover' +import { crumbs, layoutMap, nodeRadius } from './discover-map' + +const node = (key: string, more: Partial<DiscoveryMapNode> = {}): DiscoveryMapNode => ({ + key, + name: key, + tags: [], + owned: false, + seed: false, + center: false, + blocked: false, + hop: 1, + seeds: 1, + score: 0.5, + known: true, + ...more, +}) + +const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(a.x - b.x, a.y - b.y) + +describe('layoutMap', () => { + const map = { + nodes: [node('center', { center: true, hop: 0 }), node('close'), node('far'), node('other')], + edges: [ + { from: 'center', to: 'close', score: 0.95, sources: [] }, + { from: 'center', to: 'far', score: 0.1, sources: [] }, + { from: 'far', to: 'other', score: 0.9, sources: [] }, + ], + } + + it('keeps the artist a map is of in the middle, and alike artists close', () => { + const { at, box } = layoutMap(map) + expect(at.get('center')).toEqual({ x: 0, y: 0 }) + expect(dist(at.get('center')!, at.get('close')!)).toBeLessThan(dist(at.get('center')!, at.get('far')!)) + for (const p of at.values()) { + expect(p.x).toBeGreaterThanOrEqual(box.x) + expect(p.x).toBeLessThanOrEqual(box.x + box.w) + expect(p.y).toBeGreaterThanOrEqual(box.y) + expect(p.y).toBeLessThanOrEqual(box.y + box.h) + } + }) + + it('starts artists where they were', () => { + const first = layoutMap(map) + const again = layoutMap(map, first.at, 0) + expect(again.at.get('far')).toEqual(first.at.get('far')) + }) + + it('lays out nothing', () => { + expect(layoutMap({ nodes: [], edges: [] }).at.size).toBe(0) + }) +}) + +describe('nodeRadius', () => { + it('grows with fame', () => { + expect(nodeRadius({ fans: 5_000_000 })).toBeGreaterThan(nodeRadius({ fans: 500 })) + expect(nodeRadius({ center: true })).toBeGreaterThanOrEqual(16) + }) +}) + +describe('crumbs', () => { + it('retraces the set and each re-center', () => { + expect(crumbs('Moody', ['Slowdive', 'Lush'])).toEqual([ + { label: 'Moody', path: [] }, + { label: 'Slowdive', path: ['Slowdive'] }, + { label: 'Lush', path: ['Slowdive', 'Lush'] }, + ]) + expect(crumbs(undefined, ['Hum'])).toEqual([{ label: 'Hum', path: ['Hum'] }]) + }) +}) diff --git a/web/src/lib/discover-map.ts b/web/src/lib/discover-map.ts new file mode 100644 index 0000000..e512c6d --- /dev/null +++ b/web/src/lib/discover-map.ts @@ -0,0 +1,132 @@ +import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, type SimulationLinkDatum, type SimulationNodeDatum } from 'd3-force' +import type { DiscoveryMap, DiscoveryMapNode } from './discover' + +// Laying out the discovery map (MAD-821): a force-directed graph where +// alike artists sit close, and well-known ones are bigger. It's worked +// out all at once, off screen, then drawn; the map tweens from the last +// layout to the next, so re-centering glides instead of jumping. + +export type Point = { x: number; y: number } +export type Box = { x: number; y: number; w: number; h: number } +export type Layout = { at: Map<string, Point>; box: Box } + +/** A node's radius: bigger for better known, and for the artist the map is of. */ +export function nodeRadius(n: Partial<Pick<DiscoveryMapNode, 'fans' | 'center' | 'seed'>>) { + const fame = n.fans ? Math.min(Math.log10(n.fans) / 7, 1) : 0.3 + const r = 6 + 12 * fame + return n.center ? Math.max(r, 16) : n.seed ? Math.max(r, 11) : r +} + +/** About how wide a label is, at the map's font size. */ +function labelWidth(name: string) { + return Math.min(name.length, 22) * 6.2 +} + +type SimNode = SimulationNodeDatum & { key: string; r: number; w: number } +type SimLink = SimulationLinkDatum<SimNode> & { score: number } + +/** + * Lays a map out. Artists that were on the last layout start where they + * were, and new ones beside the artist they're most like, so a map that + * changes a little moves a little. The artist a map is of sits in the + * middle. + */ +export function layoutMap(map: Pick<DiscoveryMap, 'nodes' | 'edges'>, prev?: Map<string, Point>, ticks = 300): Layout { + const nodes: SimNode[] = map.nodes.map((n) => ({ key: n.key, r: nodeRadius(n), w: labelWidth(n.name) })) + const byKey = new Map(nodes.map((n) => [n.key, n])) + const links: SimLink[] = map.edges + .filter((e) => byKey.has(e.from) && byKey.has(e.to)) + .map((e) => ({ source: e.from, target: e.to, score: e.score })) + + // Where to start: where they were, else beside their nearest neighbor + // that has a place, else on a ring by rank (golden angle). + const nearest = new Map<string, { other: string; score: number }>() + for (const e of map.edges) { + for (const [a, b] of [ + [e.from, e.to], + [e.to, e.from], + ]) { + const cur = nearest.get(a) + if (prev?.has(b) && (!cur || e.score > cur.score)) nearest.set(a, { other: b, score: e.score }) + } + } + nodes.forEach((n, i) => { + const was = prev?.get(n.key) + const near = nearest.get(n.key) + const by = near && prev?.get(near.other) + const angle = i * 2.39996 + if (was) { + n.x = was.x + n.y = was.y + } else if (by) { + n.x = by.x + Math.cos(angle) * 30 + n.y = by.y + Math.sin(angle) * 30 + } else { + const d = i === 0 ? 0 : 40 + 14 * Math.sqrt(i) + n.x = Math.cos(angle) * d + n.y = Math.sin(angle) * d + } + }) + const center = map.nodes.find((n) => n.center) + if (center) { + const c = byKey.get(center.key)! + c.fx = 0 + c.fy = 0 + } + + const sim = forceSimulation(nodes) + .force( + 'link', + forceLink<SimNode, SimLink>(links) + .id((n) => n.key) + .distance((l) => 34 + 120 * (1 - l.score)) + .strength((l) => 0.15 + 0.5 * l.score), + ) + .force('charge', forceManyBody<SimNode>().strength(-160).distanceMax(400)) + .force('collide', forceCollide<SimNode>((n) => Math.max(n.r + 4, n.w / 2.4)).iterations(2)) + .force('x', forceX(0).strength(0.05)) + .force('y', forceY(0).strength(0.05)) + .stop() + // A map that only moved a little settles quicker. + sim.alpha(prev && prev.size > 0 ? 0.5 : 1) + for (let i = 0; i < ticks; i++) sim.tick() + + const at = new Map<string, Point>() + let x0 = Infinity + let y0 = Infinity + let x1 = -Infinity + let y1 = -Infinity + for (const n of nodes) { + const x = n.x ?? 0 + const y = n.y ?? 0 + at.set(n.key, { x, y }) + x0 = Math.min(x0, x - Math.max(n.r, n.w / 2)) + x1 = Math.max(x1, x + Math.max(n.r, n.w / 2)) + y0 = Math.min(y0, y - n.r) + y1 = Math.max(y1, y + n.r + 16) // the label below + } + if (nodes.length === 0) return { at, box: { x: -100, y: -100, w: 200, h: 200 } } + const pad = 12 + // Never so tight that one artist fills the screen. + const w = Math.max(x1 - x0 + 2 * pad, 240) + const h = Math.max(y1 - y0 + 2 * pad, 240) + return { at, box: { x: (x0 + x1) / 2 - w / 2, y: (y0 + y1) / 2 - h / 2, w, h } } +} + +/** Partway from a to b, t from 0 to 1. */ +export function between(a: Point | undefined, b: Point, t: number): Point { + if (!a) return b + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t } +} + +export function betweenBox(a: Box, b: Box, t: number): Box { + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, w: a.w + (b.w - a.w) * t, h: a.h + (b.h - a.h) * t } +} + +/** "Moody post-rock › Slowdive › Lush": where you've been on the map. */ +export function crumbs(setName: string | undefined, path: readonly string[]) { + const out: { label: string; path: string[] }[] = [] + if (setName) out.push({ label: setName, path: [] }) + path.forEach((name, i) => out.push({ label: name, path: path.slice(0, i + 1) })) + return out +} diff --git a/web/src/lib/discover.ts b/web/src/lib/discover.ts index 92e35b2..d628cfc 100644 --- a/web/src/lib/discover.ts +++ b/web/src/lib/discover.ts @@ -14,6 +14,9 @@ export type DiscoveryFeed = components['schemas']['DiscoveryFeed'] export type DiscoveryMode = components['schemas']['DiscoveryMode'] export type DiscoveryArtist = components['schemas']['DiscoveryArtist'] export type DiscoveryLibrary = components['schemas']['DiscoveryLibrary'] +export type DiscoveryMap = components['schemas']['DiscoveryMap'] +export type DiscoveryMapNode = components['schemas']['DiscoveryMapNode'] +export type DiscoveryMapEdge = components['schemas']['DiscoveryMapEdge'] export const setsQuery = queryOptions({ queryKey: ['discover', 'sets'], @@ -69,11 +72,32 @@ export const artistSearchQuery = (q: string) => staleTime: 5 * 60_000, }) +/** What a map is of: one of your sets, or any artist. */ +export type MapOf = { setId: string } | { artist: string } + +export const mapQuery = (of: MapOf) => + queryOptions({ + queryKey: ['discover', 'map', of], + queryFn: ({ signal }) => unwrap(api.GET('/me/discover/map', { params: { query: of }, signal })), + staleTime: 5 * 60_000, + // Artists the server didn't know yet are being looked up: look again + // a few times, and the map fills out. + refetchInterval: (q) => (q.state.data?.partial && q.state.dataUpdateCount < 4 ? 6_000 : false), + }) + +export const artistSongsQuery = (artist: string) => + queryOptions({ + queryKey: ['discover', 'songs', artist], + queryFn: ({ signal }) => unwrap(api.GET('/me/discover/songs', { params: { query: { artist } }, signal })), + staleTime: 5 * 60_000, + }) + /** Puts a changed set in the cache, refreshes the list, and drops its stale feed. */ export function setChanged(qc: QueryClient, set: DiscoverySet) { qc.setQueryData(setQuery(set.id).queryKey, set) void qc.invalidateQueries({ queryKey: setsQuery.queryKey, exact: true }) void qc.resetQueries({ queryKey: feedQuery(set.id).queryKey }) + void qc.invalidateQueries({ queryKey: mapQuery({ setId: set.id }).queryKey }) } export function createSet(name: string, artists: { name: string; mbid?: string }[] = []) { diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 4233c8a..24c4661 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as AppAuthedRoomRouteImport } from './routes/_app/_authed/room' import { Route as AppAuthedSearchRouteImport } from './routes/_app/_authed/search' import { Route as AppAuthedDiscoverIndexRouteImport } from './routes/_app/_authed/discover.index' import { Route as AppAuthedDiscoverSetIdRouteImport } from './routes/_app/_authed/discover.$setId' +import { Route as AppAuthedDiscoverMapRouteImport } from './routes/_app/_authed/discover.map' import { Route as AppAuthedGenreLinkIdRouteImport } from './routes/_app/_authed/genre.$linkId' import { Route as AppAuthedLibraryIndexRouteImport } from './routes/_app/_authed/library.index' import { Route as AppAuthedLibraryPlaylistIdRouteImport } from './routes/_app/_authed/library.$playlistId' @@ -115,6 +116,11 @@ const AppAuthedDiscoverSetIdRoute = AppAuthedDiscoverSetIdRouteImport.update({ path: '/discover/$setId', getParentRoute: () => AppAuthedRoute, } as any) +const AppAuthedDiscoverMapRoute = AppAuthedDiscoverMapRouteImport.update({ + id: '/discover/map', + path: '/discover/map', + getParentRoute: () => AppAuthedRoute, +} as any) const AppAuthedGenreLinkIdRoute = AppAuthedGenreLinkIdRouteImport.update({ id: '/genre/$linkId', path: '/genre/$linkId', @@ -192,6 +198,7 @@ export interface FileRoutesByFullPath { '/room': typeof AppAuthedRoomRoute '/search': typeof AppAuthedSearchRoute '/discover/$setId': typeof AppAuthedDiscoverSetIdRoute + '/discover/map': typeof AppAuthedDiscoverMapRoute '/genre/$linkId': typeof AppAuthedGenreLinkIdRoute '/library/$playlistId': typeof AppAuthedLibraryPlaylistIdRoute '/settings/people': typeof AppAuthedSettingsPeopleRoute @@ -219,6 +226,7 @@ export interface FileRoutesByTo { '/room': typeof AppAuthedRoomRoute '/search': typeof AppAuthedSearchRoute '/discover/$setId': typeof AppAuthedDiscoverSetIdRoute + '/discover/map': typeof AppAuthedDiscoverMapRoute '/genre/$linkId': typeof AppAuthedGenreLinkIdRoute '/library/$playlistId': typeof AppAuthedLibraryPlaylistIdRoute '/settings/people': typeof AppAuthedSettingsPeopleRoute @@ -249,6 +257,7 @@ export interface FileRoutesById { '/_app/_authed/search': typeof AppAuthedSearchRoute '/_app/_authed/': typeof AppAuthedIndexRoute '/_app/_authed/discover/$setId': typeof AppAuthedDiscoverSetIdRoute + '/_app/_authed/discover/map': typeof AppAuthedDiscoverMapRoute '/_app/_authed/genre/$linkId': typeof AppAuthedGenreLinkIdRoute '/_app/_authed/library/$playlistId': typeof AppAuthedLibraryPlaylistIdRoute '/_app/_authed/settings/people': typeof AppAuthedSettingsPeopleRoute @@ -278,6 +287,7 @@ export interface FileRouteTypes { | '/room' | '/search' | '/discover/$setId' + | '/discover/map' | '/genre/$linkId' | '/library/$playlistId' | '/settings/people' @@ -305,6 +315,7 @@ export interface FileRouteTypes { | '/room' | '/search' | '/discover/$setId' + | '/discover/map' | '/genre/$linkId' | '/library/$playlistId' | '/settings/people' @@ -334,6 +345,7 @@ export interface FileRouteTypes { | '/_app/_authed/search' | '/_app/_authed/' | '/_app/_authed/discover/$setId' + | '/_app/_authed/discover/map' | '/_app/_authed/genre/$linkId' | '/_app/_authed/library/$playlistId' | '/_app/_authed/settings/people' @@ -472,6 +484,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAuthedDiscoverSetIdRouteImport parentRoute: typeof AppAuthedRoute } + '/_app/_authed/discover/map': { + id: '/_app/_authed/discover/map' + path: '/discover/map' + fullPath: '/discover/map' + preLoaderRoute: typeof AppAuthedDiscoverMapRouteImport + parentRoute: typeof AppAuthedRoute + } '/_app/_authed/genre/$linkId': { id: '/_app/_authed/genre/$linkId' path: '/genre/$linkId' @@ -559,6 +578,7 @@ interface AppAuthedRouteChildren { AppAuthedSearchRoute: typeof AppAuthedSearchRoute AppAuthedIndexRoute: typeof AppAuthedIndexRoute AppAuthedDiscoverSetIdRoute: typeof AppAuthedDiscoverSetIdRoute + AppAuthedDiscoverMapRoute: typeof AppAuthedDiscoverMapRoute AppAuthedGenreLinkIdRoute: typeof AppAuthedGenreLinkIdRoute AppAuthedLibraryPlaylistIdRoute: typeof AppAuthedLibraryPlaylistIdRoute AppAuthedSettingsPeopleRoute: typeof AppAuthedSettingsPeopleRoute @@ -580,6 +600,7 @@ const AppAuthedRouteChildren: AppAuthedRouteChildren = { AppAuthedSearchRoute: AppAuthedSearchRoute, AppAuthedIndexRoute: AppAuthedIndexRoute, AppAuthedDiscoverSetIdRoute: AppAuthedDiscoverSetIdRoute, + AppAuthedDiscoverMapRoute: AppAuthedDiscoverMapRoute, AppAuthedGenreLinkIdRoute: AppAuthedGenreLinkIdRoute, AppAuthedLibraryPlaylistIdRoute: AppAuthedLibraryPlaylistIdRoute, AppAuthedSettingsPeopleRoute: AppAuthedSettingsPeopleRoute, diff --git a/web/src/routes/_app/_authed/discover.$setId.tsx b/web/src/routes/_app/_authed/discover.$setId.tsx index efafd3f..3cdbf80 100644 --- a/web/src/routes/_app/_authed/discover.$setId.tsx +++ b/web/src/routes/_app/_authed/discover.$setId.tsx @@ -8,6 +8,7 @@ import { BackButton } from '@/components/back-button' import { ConfirmButton } from '@/components/confirm-button' import { ArtistSearch } from '@/components/discover/artist-search' import { FeedCard } from '@/components/discover/feed-card' +import { SetTabs } from '@/components/discover/set-tabs' import { Notice } from '@/components/notice' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -141,6 +142,7 @@ function Header({ set }: { set: DiscoverySet }) { </div> </div> )} + <SetTabs setId={set.id} on="feed" className="self-start" /> </header> ) } diff --git a/web/src/routes/_app/_authed/discover.index.tsx b/web/src/routes/_app/_authed/discover.index.tsx index 2ff9f29..9a69c21 100644 --- a/web/src/routes/_app/_authed/discover.index.tsx +++ b/web/src/routes/_app/_authed/discover.index.tsx @@ -1,9 +1,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' -import { Compass, LoaderCircle, Plus, RefreshCw, X } from 'lucide-react' +import { Compass, Crosshair, LoaderCircle, Plus, RefreshCw, X } from 'lucide-react' import { motion } from 'motion/react' import { useState } from 'react' import { errorMessage } from '@/api/errors' +import { ArtistSearch } from '@/components/discover/artist-search' import { PageHeader } from '@/components/page-header' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -86,6 +87,7 @@ function Discover() { ))} </motion.ul> )} + <MapStart /> <LibraryStatus /> <Blocks /> </> @@ -143,6 +145,23 @@ function SetCard({ set: s }: { set: DiscoverySetSummary }) { ) } +/** The map, from any artist: how you explore something entirely new. */ +function MapStart() { + const navigate = useNavigate() + return ( + <section className="mt-10"> + <h2 className="mb-1 px-1 text-headline">Map</h2> + <p className="mb-3 px-1 text-sm text-muted-foreground">Start from any artist and wander: tap one to hear them, or re-center and keep going.</p> + <ArtistSearch + have={new Set()} + placeholder="Map an artist" + icon={<Crosshair className="size-4" />} + onPick={(name) => void navigate({ to: '/discover/map', search: { path: [name] } })} + /> + </section> + ) +} + /** What's left out: the songs on your own Navidrome, indexed in the background. */ function LibraryStatus() { const qc = useQueryClient() diff --git a/web/src/routes/_app/_authed/discover.map.tsx b/web/src/routes/_app/_authed/discover.map.tsx new file mode 100644 index 0000000..b03aa41 --- /dev/null +++ b/web/src/routes/_app/_authed/discover.map.tsx @@ -0,0 +1,241 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { ChevronRight, Crosshair, LoaderCircle, RefreshCw } from 'lucide-react' +import { Fragment, useEffect, useMemo, useState } from 'react' +import { errorMessage } from '@/api/errors' +import { BackButton } from '@/components/back-button' +import { AddToSetSheet } from '@/components/discover/add-to-set' +import { ArtistSearch } from '@/components/discover/artist-search' +import { MapArtistSheet } from '@/components/discover/map-artist-sheet' +import { MapGraph } from '@/components/discover/map-graph' +import { SetTabs } from '@/components/discover/set-tabs' +import { Notice } from '@/components/notice' +import { Button } from '@/components/ui/button' +import { useMe } from '@/lib/auth' +import { + addArtist, + block, + blocksQuery, + mapQuery, + setChanged, + setQuery, + stopPreview, + unblock, + type DiscoveryMapNode, + type MapOf, +} from '@/lib/discover' +import { crumbs } from '@/lib/discover-map' +import { tap } from '@/lib/haptics' +import { toast } from '@/lib/toast' + +type Search = { + /** The set the map started from. */ + set?: string + /** The artists re-centered on, in order: the map is of the last. */ + path?: string[] +} + +export const Route = createFileRoute('/_app/_authed/discover/map')({ + validateSearch: (search: Record<string, unknown>): Search => ({ + set: typeof search.set === 'string' && search.set ? search.set : undefined, + path: Array.isArray(search.path) ? search.path.filter((p): p is string => typeof p === 'string' && p.trim() !== '').slice(-30) : undefined, + }), + component: MapPage, +}) + +/** + * The discovery map (MAD-821): artists around a set's seeds, or around any + * artist, in the spirit of music-map.com. Tap an artist to hear their + * songs, or re-center on them and wander; Back retraces the way. + */ +function MapPage() { + const me = useMe() + const { set: setId, path = [] } = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + const center = path.at(-1) + const of: MapOf | undefined = center ? { artist: center } : setId ? { setId } : undefined + const set = useQuery({ ...setQuery(setId ?? ''), enabled: !!setId && !me.guest }) + // A preview stops when you leave. + useEffect(() => stopPreview, []) + + const go = (next: string[]) => { + tap() + void navigate({ search: { set: setId, path: next.length ? next : undefined } }) + } + + if (me.guest) { + return ( + <> + <BackButton fallback="/discover" /> + <p className="glass mt-4 rounded-3xl p-5 text-sm text-muted-foreground">Discover is for members.</p> + </> + ) + } + const trail = crumbs(setId ? (set.data?.name ?? '…') : undefined, path) + return ( + <> + <BackButton fallback="/discover" /> + <header className="flex flex-col gap-3 pt-2 pb-4"> + <h1 className="min-w-0 text-display break-words">{center ?? set.data?.name ?? 'Map'}</h1> + {setId && <SetTabs setId={setId} on="map" className="self-start" />} + </header> + <ArtistSearch + have={new Set(center ? [center.toLowerCase()] : [])} + placeholder="Start from any artist" + icon={<Crosshair className="size-4" />} + onPick={(name) => go([...path, name])} + /> + {trail.length > 1 && ( + <nav aria-label="Where you've been" className="mt-4 flex flex-wrap items-center gap-1 px-1 text-sm"> + {trail.map((c, i) => ( + <Fragment key={`${i}-${c.label}`}> + {i > 0 && <ChevronRight className="size-3.5 text-muted-foreground" />} + {i === trail.length - 1 ? ( + <span aria-current="location" className="font-medium"> + {c.label} + </span> + ) : ( + <button type="button" onClick={() => go(c.path)} className="rounded text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"> + {c.label} + </button> + )} + </Fragment> + ))} + </nav> + )} + {of ? ( + <MapView of={of} setId={setId} setName={set.data?.name} onRecenter={(name) => go([...path, name])} /> + ) : ( + <p className="glass mt-6 rounded-3xl p-5 text-sm text-muted-foreground"> + Search for an artist, any artist, and see who's near them. Tap one to hear their songs, or re-center on them and keep going. + </p> + )} + </> + ) +} + +function MapView({ of, setId, setName, onRecenter }: { of: MapOf; setId?: string; setName?: string; onRecenter: (name: string) => void }) { + const qc = useQueryClient() + const map = useQuery(mapQuery(of)) + const [open, setOpen] = useState<DiscoveryMapNode>() + const [adding, setAdding] = useState<string>() + const data = map.data + + const addToThis = useMutation({ + mutationFn: (n: DiscoveryMapNode) => addArtist(setId!, n.name), + onMutate: () => tap(), + onSuccess: (s, n) => { + setChanged(qc, s) + toast({ message: `Added ${n.name} to ${s.name}` }) + setOpen(undefined) + }, + onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), + }) + const notForMe = useMutation({ + mutationFn: (n: DiscoveryMapNode) => block(n.name), + onMutate: () => { + tap() + setOpen(undefined) + }, + onSuccess: (b) => { + void qc.invalidateQueries({ queryKey: blocksQuery.queryKey }) + void qc.invalidateQueries({ queryKey: ['discover', 'map'] }) + qc.removeQueries({ queryKey: ['discover', 'feed'] }) + toast({ + message: `No more ${b.name}`, + action: { + label: 'Undo', + onClick: () => + void unblock(b.key).then(() => { + void qc.invalidateQueries({ queryKey: blocksQuery.queryKey }) + void qc.invalidateQueries({ queryKey: ['discover', 'map'] }) + qc.removeQueries({ queryKey: ['discover', 'feed'] }) + }), + }, + }) + }, + onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), + }) + + const mine = useMemo(() => (data?.nodes ?? []).filter((n) => n.owned).length, [data]) + + return ( + <section className="mt-4"> + <div className="glass relative aspect-square max-h-[70dvh] w-full overflow-hidden rounded-3xl sm:aspect-[4/3]"> + {map.isPending ? ( + <div className="grid h-full place-items-center"> + <div className="flex flex-col items-center gap-3 text-sm text-muted-foreground"> + <LoaderCircle className="size-6 animate-spin" /> + Mapping who's near… + </div> + </div> + ) : map.isError && !data ? ( + <div className="grid h-full place-items-center p-6"> + <Notice>{errorMessage(map.error)}</Notice> + </div> + ) : data && data.nodes.length > 1 ? ( + <MapGraph map={data} selected={open?.key} onSelect={(n) => setOpen(n)} className="p-2" /> + ) : ( + <div className="grid h-full place-items-center p-6 text-center text-sm text-muted-foreground"> + {data?.noGraph + ? "This server doesn't know how music relates yet: its admin can turn on Last.fm, ListenBrainz or Deezer." + : data?.set && data.set.artists.length === 0 + ? 'Add an artist or two to this set, and the map grows around them.' + : data?.partial + ? 'Looking them up. The map fills in shortly.' + : 'Nobody known turned up near here.'} + </div> + )} + {data?.partial && ( + <span className="glass absolute top-3 right-3 flex items-center gap-1.5 rounded-full px-3 py-1 text-caption text-muted-foreground"> + <LoaderCircle className="size-3 animate-spin" /> + Filling in + </span> + )} + </div> + {data && data.nodes.length > 1 && ( + <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 px-1 text-caption text-muted-foreground"> + <Legend kind="new">New to you</Legend> + <Legend kind="owned">{`In your library (${mine})`}</Legend> + <Legend kind="ring">{data.set ? 'Seed' : 'Center'}</Legend> + {data.set && <Legend kind="halo">Near several seeds</Legend>} + <Button variant="ghost" size="sm" className="ml-auto" disabled={map.isFetching} onClick={() => void map.refetch()}> + <RefreshCw data-icon="inline-start" className={map.isFetching ? 'animate-spin' : undefined} /> + Reload + </Button> + </div> + )} + {data?.library.building && ( + <p className="mt-2 px-1 text-caption text-muted-foreground">Still indexing your library, so an artist you have might not be dimmed yet.</p> + )} + <MapArtistSheet + node={open} + onOpenChange={(o) => !o && setOpen(undefined)} + onRecenter={(n) => { + setOpen(undefined) + onRecenter(n.name) + }} + onAddToSet={(n) => { + setOpen(undefined) + setAdding(n.name) + }} + addTo={setId && setName ? { name: setName, add: (n) => addToThis.mutate(n), pending: addToThis.isPending } : undefined} + onBlock={(n) => notForMe.mutate(n)} + /> + <AddToSetSheet artist={adding ?? ''} open={!!adding} onOpenChange={(o) => !o && setAdding(undefined)} /> + </section> + ) +} + +function Legend({ kind, children }: { kind: 'new' | 'owned' | 'ring' | 'halo'; children: string }) { + return ( + <span className="flex items-center gap-1.5"> + <svg viewBox="-8 -8 16 16" className="size-3.5" aria-hidden> + {kind === 'halo' && <circle r={7.5} fill="var(--primary)" fillOpacity={0.15} />} + {kind === 'ring' && <circle r={6.5} fill="none" stroke="var(--primary)" strokeWidth={1.5} />} + <circle r={kind === 'ring' ? 3.5 : 4.5} fill={kind === 'owned' ? 'var(--muted-foreground)' : 'var(--primary)'} fillOpacity={kind === 'owned' ? 0.35 : 0.95} /> + </svg> + {children} + </span> + ) +}