Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
187 changes: 187 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
16 changes: 15 additions & 1 deletion docs/adr/0017-personal-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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=<artist> <title>` 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.
Expand Down
118 changes: 89 additions & 29 deletions server/internal/discover/feed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading