From 96cfa0929a1c00bad76680f19e884bfdef069ada Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 7 Oct 2026 14:37:33 -0500 Subject: [PATCH] Suggestions on the DJ engine (MAD-759) Search's "keep the vibe going" lists now ask the same DJ as autopilot when the server has a music graph: the taste profile, the walk through similar artists, scoring and cross-service matching. - "Your vibe" narrows the taste to the asker's own (dj.Request.Member); "group vibe" uses the room's, members' tastes mixed as for autopilot. source=queue reads the taste from the songs playing and waiting (dj.Request.Queued), with what played through standing in when nothing is queued. What the room heard and turned away stays the room's. - Picks are found only on the asker's links that search, so every suggestion can still be queued by them. Each is credited to the member's song it leads from. - Choose takes a longer list: n picks draw 2n candidates from a shortlist at least that long, and look for up to 6 at once on the services, in the draw's order (autopilot's two picks try the same candidates as before). - Without a graph, or when it knows nothing near the vibe, the services' own recommendations stand in, as before. - Removed duplicated Finder paths: Finder.Like replaces the recommend-or-search branch in suggestions and quick picks, and suggest.TrackOf/ArtistKey defer to dj's. - ADR 0010 and 0012 describe it. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0010-suggestions.md | 2 + docs/adr/0012-smart-dj.md | 4 +- server/cmd/syncphony/main.go | 5 +- server/internal/autopilot/autopilot.go | 4 +- server/internal/dj/dj.go | 111 ++++++++++----- server/internal/dj/dj_test.go | 36 +++++ server/internal/dj/live_test.go | 4 +- server/internal/dj/profile.go | 44 ++++++ server/internal/dj/score.go | 11 +- server/internal/httpapi/quickpicks.go | 15 +- server/internal/httpapi/suggestions.go | 3 +- server/internal/suggest/dj_test.go | 188 +++++++++++++++++++++++++ server/internal/suggest/find.go | 35 +++-- server/internal/suggest/suggest.go | 122 +++++++++++----- 14 files changed, 472 insertions(+), 112 deletions(-) create mode 100644 server/internal/suggest/dj_test.go diff --git a/docs/adr/0010-suggestions.md b/docs/adr/0010-suggestions.md index 06c6c06..3614cb3 100644 --- a/docs/adr/0010-suggestions.md +++ b/docs/adr/0010-suggestions.md @@ -20,6 +20,8 @@ The parts of autopilot that find songs like a seed move into `internal/suggest`: - **Songs** come from the asker's usable links only (their own and shared ones), so every suggestion can be queued by them. Services that recommend give similar songs and the artist's top songs. Services that only search (Spotify, nugs.net) give more by the seed's artist, found by searching their name. Each seed asks at most two services, recommenders first. - **Not suggested:** anything waiting, playing, or in the room's last 200 plays — nor songs by an artist the room turned away. That's the DJ's rule (ADR 0012): a skip turns that vibe away while it outweighs the room's liking (full listens, queued songs, hearts), and it fades with a 2-hour half-life, so a skip long ago no longer counts (MAD-762). Songs are compared the way autopilot compares them. - **The list** mixes seeds, taking turns. Picks within a seed are random among the top of its candidates, so a reshuffle gives a different list. Each suggestion carries its seed (`because`), for a byline like "Like Heroes, Bob's pick". +- **The DJ picks them (MAD-759).** When the server has a music graph (ADR 0012), suggestions come from the same DJ as autopilot: the room's taste profile, the walk through similar artists, scoring, and cross-service matching, at explore 25 (near the vibe). `mine` narrows the taste to the asker's own (`dj.Request.Member`). `group` uses the room's, members' tastes mixed as for autopilot. `source=queue` reads the taste from the members' songs playing and waiting (`dj.Request.Queued`), the asker's for `mine`; what played through stands in when nothing's queued. What the room heard and turned away stays the room's either way. The DJ finds each pick only on the asker's links that search, so every suggestion can still be queued by them. A list of n draws 2n candidates from a shortlist at least that long, at most 2 songs per artist, and looks for up to 6 at once. Each suggestion's `because` is the member's song by the artist it leads from; a pick only autopilot's songs led to has none, and is left out. +- **Without the DJ.** With no graph, or when it knows nothing near the vibe, the services' own recommendations stand in, as described above (seeds, `Finder.Like`). - **Cost:** a list is cached in memory for two minutes per room, person, scope, source and queue version; `refresh=true` builds a new one. Building one is bounded at 15 seconds; whatever was found by then is returned. - Guests can ask in their own room; they get songs from the server's shared services. diff --git a/docs/adr/0012-smart-dj.md b/docs/adr/0012-smart-dj.md index 529a517..d5cfeb7 100644 --- a/docs/adr/0012-smart-dj.md +++ b/docs/adr/0012-smart-dj.md @@ -81,7 +81,7 @@ MusicBrainz joins when `SYNCPHONY_MUSICBRAINZ_URL` isn't off. `TestLive` in `int ## The DJ (stage 2) -`internal/dj` picks songs, and `autopilot` asks it first. It knows nothing about queues or turns: it's given what the room did, and returns songs to try, best first. It doesn't import `suggest`, so suggestions can move onto it later (MAD-759). +`internal/dj` picks songs, and `autopilot` asks it first. It knows nothing about queues or turns: it's given what the room did, and returns songs to try, best first. It doesn't import `suggest`. Suggestions (ADR 0010) ask it too (MAD-759). ### The room's taste (MAD-753) @@ -253,6 +253,6 @@ If the graph has no sources, knows nothing near the room's taste, or none of its - The first fill after a cold start knows only the artists it fetched in time. The warmer catches up from the songs being queued. - `AutopilotInfo.reason` is stored with each song; it isn't in the API until MAD-760. - A room's long-term taste is the room's: it's deleted with the room. It names members by ID, and keeps an artist only while its likes or skips are above 0.005, about 15 nights of silence for one play. -- Suggestions (ADR 0010) turn artists away by the same rule as the DJ (`suggest.TurnedAway` reads `dj.Profile.Avoid`), so a skip fades there too (MAD-762). MAD-759 moves the rest of suggestions onto the DJ. +- Suggestions (ADR 0010) come from the DJ too (MAD-759): the asker's taste for "your vibe", the room's for "group vibe", found only on the asker's links. Without a graph they fall back on the services' recommendations, turning artists away by the DJ's rule (`suggest.TurnedAway` reads `dj.Profile.Avoid`), so a skip fades there too (MAD-762). - Tempo is often unknown: Deezer has a BPM for some songs and 0 for many, and few libraries tag it. Set flow treats 0 as unknown, so a room with little known about its songs plays as it did before; it flows better as the cache warms. - Energy is a guess from tags and tempo. A band tagged "rock" plays its ballads as rock. diff --git a/server/cmd/syncphony/main.go b/server/cmd/syncphony/main.go index 6b5605d..17b6ecf 100644 --- a/server/cmd/syncphony/main.go +++ b/server/cmd/syncphony/main.go @@ -226,9 +226,12 @@ func run() error { pilot := autopilot.New(db, roomSvc, queueSvc, a.links, presence) pilot.Player = player pilot.Memory = &dj.Memory{DB: db} + // Suggestions (ADR 0010) ask the same DJ. + suggestions := suggest.New(db, roomSvc, a.links) if len(graph.Sources()) > 0 { pilot.Graph = graph pilot.Memory.Graph = graph + suggestions.Graph = graph } defer pilot.Close() onChange, onUpdate := queueSvc.OnChange, roomSvc.OnUpdate @@ -243,7 +246,7 @@ func run() error { nightSvc := nights.New(db, a.bus) api := &httpapi.Server{ Version: version, StartedAt: time.Now().UTC(), Admin: admin.New(db, filepath.Join(cfg.DataDir, "backups")), Auth: accounts, Links: a.links, Lyrics: lyricsSvc, LinerNotes: notes, Artwork: art, Palettes: palettes, - Rooms: roomSvc, Queue: queueSvc, Playback: player, Nights: nightSvc, Suggest: suggest.New(db, roomSvc, a.links), Autopilot: pilot, + Rooms: roomSvc, Queue: queueSvc, Playback: player, Nights: nightSvc, Suggest: suggestions, Autopilot: pilot, Bus: a.bus, Presence: presence, BaseURL: cfg.BaseURL, TrustedProxies: cfg.TrustedProxies, } diff --git a/server/internal/autopilot/autopilot.go b/server/internal/autopilot/autopilot.go index 4ca57c6..1a287e3 100644 --- a/server/internal/autopilot/autopilot.go +++ b/server/internal/autopilot/autopilot.go @@ -357,7 +357,7 @@ func (f *fill) remember(upcoming []store.QueueItem) { for i, h := range f.history { f.seen.Item(h.QueueItem) if i < discoveryAvoid { - if a := suggest.ArtistKey(suggest.TrackOf(h.QueueItem)); a != "" { + if a := suggest.ArtistKey(dj.TrackOf(h.QueueItem)); a != "" { f.recentArtists[a] = true } } @@ -556,7 +556,7 @@ func (f *fill) fromGraph(ctx context.Context, users []string) []pick { }} } if p.Seed.ID != "" { - seed := suggest.TrackOf(p.Seed) + seed := dj.TrackOf(p.Seed) info.SeedItemID, info.SeedTitle = p.Seed.ID, seed.Title if len(seed.Artists) > 0 { info.SeedArtist = seed.Artists[0].Name diff --git a/server/internal/dj/dj.go b/server/internal/dj/dj.go index 0567d7d..f291af4 100644 --- a/server/internal/dj/dj.go +++ b/server/internal/dj/dj.go @@ -15,6 +15,7 @@ import ( "cmp" "context" "log/slog" + "sync" "time" "github.com/madeofpendletonwool/syncphony/server/internal/match" @@ -48,6 +49,8 @@ const ( linksPerSong = 2 // defaultWant is how many picks Choose returns, unless asked. defaultWant = 2 + // findAtOnce is how many picks are looked for on services at once. + findAtOnce = 6 ) // Engine picks songs. @@ -78,6 +81,13 @@ type Request struct { // EnergyCurve follows the room's energy curve (rooms.Autopilot): a // gentle rise over a session, then settling, moved by the time of day. EnergyCurve bool + // Member narrows the taste to one member's, for their own suggestions + // ("your vibe", ADR 0010). The room's skips still turn artists away. + Member string + // Queued reads the taste from the members' songs playing and waiting + // (Member's, if set), when there are any, so a queued change of vibe + // is the vibe. + Queued bool } // Pick is a song the DJ chose. @@ -131,51 +141,82 @@ func (e *Engine) Choose(ctx context.Context, r Request) []Pick { if in.Related == nil { in.Related = Related(ctx, e.Graph) } - p := NewProfile(in) + p := NewProfile(in).narrow(in, r.Member, r.Queued) if len(p.Artists) == 0 || len(r.Links) == 0 { return nil } order := e.shortlist(ctx, p, r, in.Tags) want := cmp.Or(r.Want, defaultWant) + sessions := &lockedSessions{s: r.Sessions} var out []Pick - for _, c := range order { + taken := map[provider.TrackRef]bool{} + for len(order) > 0 && len(out) < want { if ctx.Err() != nil { break // out of time: play what was found } - t, ok := e.find(ctx, r, p, c) - if !ok { - continue - } - taste := p.Artists[c.via] - pick := Pick{Track: t, Why: Why{ - Kind: c.kind, Similarity: c.similarity, Popularity: c.popularity, Novelty: c.novelty, - Prior: c.prior, Score: c.score, DeepCut: c.deepCut, Sources: c.sources, LovedAt: c.lovedAt, Flow: c.flow, - Bridge: c.bridge, Turn: c.turn, - }} - switch { - case c.kind == KindThrowback: - pick.Why.Via = r.LongTerm.Artists[c.via].Name - case taste != nil: - pick.ForUser, pick.Seed, pick.Why.Via = taste.Fan(), taste.Seed, taste.Artist.Name + // Look for as many as are still wanted at once, in the draw's + // order, so a fill tries the same candidates as one by one. + batch := order[:min(len(order), want-len(out), findAtOnce)] + order = order[len(batch):] + found := make([]provider.Track, len(batch)) + ok := make([]bool, len(batch)) + var wg sync.WaitGroup + for i, c := range batch { + wg.Go(func() { found[i], ok[i] = e.find(ctx, r, sessions, p, c) }) } - // A bridge is for the one of its members served longer ago; a turn, - // for its member, if the song is near their taste. - switch { - case c.bridge != nil: - pick.ForUser = p.servedLongestAgo(c.bridge.Users[:]) - pick.Seed = p.memberSeed(pick.ForUser, c.reachVia[pick.ForUser]) - case c.turn != "" && c.reach[c.turn] >= bridgeReach: - pick.ForUser = c.turn - pick.Seed = p.memberSeed(c.turn, c.reachVia[c.turn]) - } - out = append(out, pick) - if len(out) >= want { - break + wg.Wait() + for i, c := range batch { + if !ok[i] || taken[found[i].Ref] { + continue + } + taken[found[i].Ref] = true + out = append(out, p.pick(r, c, found[i])) } } return out } +// pick is candidate c, found as t, with why it was chosen and whom for. +func (p Profile) pick(r Request, c candidate, t provider.Track) Pick { + taste := p.Artists[c.via] + pick := Pick{Track: t, Why: Why{ + Kind: c.kind, Similarity: c.similarity, Popularity: c.popularity, Novelty: c.novelty, + Prior: c.prior, Score: c.score, DeepCut: c.deepCut, Sources: c.sources, LovedAt: c.lovedAt, Flow: c.flow, + Bridge: c.bridge, Turn: c.turn, + }} + switch { + case c.kind == KindThrowback: + pick.Why.Via = r.LongTerm.Artists[c.via].Name + case taste != nil: + pick.ForUser, pick.Seed, pick.Why.Via = taste.Fan(), taste.Seed, taste.Artist.Name + } + // A bridge is for the one of its members served longer ago; a turn, + // for its member, if the song is near their taste. + switch { + case c.bridge != nil: + pick.ForUser = p.servedLongestAgo(c.bridge.Users[:]) + pick.Seed = p.memberSeed(pick.ForUser, c.reachVia[pick.ForUser]) + case c.turn != "" && c.reach[c.turn] >= bridgeReach: + pick.ForUser = c.turn + pick.Seed = p.memberSeed(c.turn, c.reachVia[c.turn]) + } + return pick +} + +// lockedSessions opens links one at a time, for finding picks at once: +// a Sessions like suggest.Finder isn't safe for concurrent use. The +// sessions it opens are. +type lockedSessions struct { + mu sync.Mutex + s Sessions +} + +func (l *lockedSessions) Open(ctx context.Context, linkID string) (provider.Session, bool) { + l.mu.Lock() + defer l.mu.Unlock() + return l.s.Open(ctx, linkID) +} + // shortlist returns the candidates to try, in order: drawn from the best // the walk found, scored on how they flow from what the room just heard. func (e *Engine) shortlist(ctx context.Context, p Profile, r Request, tags map[string][]musicgraph.Tag) []candidate { @@ -189,11 +230,13 @@ func (e *Engine) shortlist(ctx context.Context, p Profile, r Request, tags map[s cancel() deepCut := e.Rand(100) < deepCutPercent mx := mixFor(p) - pool := e.flowing(ctx, p, tags, diverse(mixed(score(cands, p, r.LongTerm, x, deepCut), p, mx)), r.EnergyCurve, r.Now) + // A long list (suggestions) draws from a longer shortlist. + n := max(drawn, 2*cmp.Or(r.Want, defaultWant)) + pool := e.flowing(ctx, p, tags, diverse(mixed(score(cands, p, r.LongTerm, x, deepCut), p, mx), max(shortlist, n)), r.EnergyCurve, r.Now) for i := range pool { pool[i].turn = mx.turn } - return draw(pool, drawn, lerp(tempFamiliar, tempExplore, x), e.Rand) + return draw(pool, n, lerp(tempFamiliar, tempExplore, x), e.Rand) } // CachedTags returns the tags of the artists the room played lately, as @@ -222,14 +265,14 @@ func CachedTags(ctx context.Context, g Graph, in Input) map[string][]musicgraph. // find looks for a candidate on the room's services: first the one the // song it leads from played from, then the others in order. -func (e *Engine) find(ctx context.Context, r Request, p Profile, c candidate) (provider.Track, bool) { +func (e *Engine) find(ctx context.Context, r Request, sessions Sessions, p Profile, c candidate) (provider.Track, bool) { want := provider.Track{Title: c.song.Title, ISRC: c.song.ISRC, MBID: c.song.MBID, Artists: []provider.ArtistCredit{{Name: c.song.Artist.Name}}} links := r.Links if taste := p.Artists[c.via]; taste != nil && taste.Seed.ID != "" { links = first(links, source(taste.Seed)) } for _, l := range links[:min(len(links), linksPerSong)] { - sess, ok := r.Sessions.Open(ctx, l.ID) + sess, ok := sessions.Open(ctx, l.ID) if !ok { continue } diff --git a/server/internal/dj/dj_test.go b/server/internal/dj/dj_test.go index 5c79281..b9232bc 100644 --- a/server/internal/dj/dj_test.go +++ b/server/internal/dj/dj_test.go @@ -7,6 +7,7 @@ import ( "database/sql" "encoding/json" "fmt" + "maps" "math" "slices" "sync" @@ -123,6 +124,41 @@ func TestProfileSkips(t *testing.T) { } } +// Suggestions narrow the taste to a member's, or to what's queued (ADR +// 0010); the room's skips still turn artists away. +func TestProfileNarrow(t *testing.T) { + alices := item("alice", "Hers", "One") + h := []store.ListHistoryRow{ + played(alices, now.Add(-10*time.Minute), store.EndFinished), + played(item("bob", "His", "Two"), now.Add(-20*time.Minute), store.EndFinished), + skipped(item("bob", "Skipped", "Three"), now.Add(-30*time.Minute), 5*time.Second), + } + waiting := item("bob", "Waiting", "Four") + waiting.State = store.ItemQueued + in := Input{History: h, Upcoming: []store.QueueItem{waiting}, Now: now} + room := NewProfile(in) + + mine := room.narrow(in, "alice", false) + if len(mine.Artists) != 1 || weight(mine, "Hers") != 1 || mine.Artists["hers"].Seed.ID != alices.ID { + t.Errorf("alice's taste = %v: want only Hers, seeded by her song", slices.Collect(maps.Keys(mine.Artists))) + } + if !mine.Avoid["skipped"] || !mine.Heard[SongKey("His", "Two")] { + t.Error("alice's taste forgot what the room skipped or heard") + } + if len(mine.memberVibes) != 1 { + t.Errorf("alice's taste mixes %d members", len(mine.memberVibes)) + } + + queued := room.narrow(in, "", true) + if len(queued.Artists) != 1 || weight(queued, "Waiting") != 1 { + t.Errorf("the queued taste = %v: want only Waiting", slices.Collect(maps.Keys(queued.Artists))) + } + // Alice queued nothing: what she played stands in. + if both := room.narrow(in, "alice", true); weight(both, "Hers") != 1 || len(both.Artists) != 1 { + t.Errorf("alice's queued taste = %v: want Hers", slices.Collect(maps.Keys(both.Artists))) + } +} + // A skip long ago, decayed to almost nothing, doesn't turn an artist away // (MAD-762); a recent one does. func TestProfileSkipsFade(t *testing.T) { diff --git a/server/internal/dj/live_test.go b/server/internal/dj/live_test.go index 7201fa0..c06587e 100644 --- a/server/internal/dj/live_test.go +++ b/server/internal/dj/live_test.go @@ -90,8 +90,8 @@ func TestLive(t *testing.T) { } for _, explore := range []float64{0, 0.5, 1} { cands := e.walk(t.Context(), p, lt, explore, nil) - show(fmt.Sprintf("explore %.1f, %d candidates", explore, len(cands)), diverse(score(cands, p, lt, explore, false))) + show(fmt.Sprintf("explore %.1f, %d candidates", explore, len(cands)), diverse(score(cands, p, lt, explore, false), shortlist)) } back := Throwbacks(p, lt) - show(fmt.Sprintf("a throwback fill, bringing back %v", back), diverse(score(e.walk(t.Context(), p, lt, 0.25, back), p, lt, 0.25, false))) + show(fmt.Sprintf("a throwback fill, bringing back %v", back), diverse(score(e.walk(t.Context(), p, lt, 0.25, back), p, lt, 0.25, false), shortlist)) } diff --git a/server/internal/dj/profile.go b/server/internal/dj/profile.go index 3828545..ecb326b 100644 --- a/server/internal/dj/profile.go +++ b/server/internal/dj/profile.go @@ -205,6 +205,50 @@ func NewProfile(in Input) Profile { return p } +// narrow reads a narrower taste than the room's, for suggestions (ADR +// 0010): the members' songs playing and waiting, if queued and there are +// any; and of those, one member's, if member isn't "". What the room +// heard, turned away and doubts stays the room's. +func (p Profile) narrow(in Input, member string, queued bool) Profile { + if member == "" && !queued { + return p + } + if queued { + var up []store.QueueItem + for _, it := range in.Upcoming { + if !it.IsAutopilot() && (member == "" || it.AddedBy == member) { + up = append(up, it) + } + } + if len(up) > 0 { + q := Input{Upcoming: up, Now: in.Now} + p.Artists, p.RecentSongs = tastes(q, reactions(q, profileReach), time.Time{}).Artists, up + } + } + if member != "" { + most := 0.0 + for _, t := range p.Artists { + most = max(most, t.ByMember[member]) + } + mine := map[string]*Taste{} + for a, t := range p.Artists { + w := t.ByMember[member] + if w <= 0 { + continue + } + seed := t.seeds[member] + mine[a] = &Taste{ + Artist: t.Artist, Weight: w / most, Plays: t.Plays, ByMember: map[string]float64{member: w / most}, + Seed: seed, seeds: map[string]store.QueueItem{member: seed}, + } + } + p.Artists = mine + p.RecentSongs = slices.DeleteFunc(slices.Clone(p.RecentSongs), func(it store.QueueItem) bool { return it.AddedBy != member }) + } + p.members(in) + return p +} + // tastes adds up the room's reactions into the artists it likes and turns // away, those since boost (if not zero) counting vibeBoost times as much. func tastes(in Input, rs []reaction, boost time.Time) Profile { diff --git a/server/internal/dj/score.go b/server/internal/dj/score.go index d447d71..1e587b3 100644 --- a/server/internal/dj/score.go +++ b/server/internal/dj/score.go @@ -28,7 +28,8 @@ const ( // shortlist is how many of the best candidates a pick is drawn from, // at most perArtist of any one artist, so a pick chooses between // artists rather than among one's songs; drawn is how many are drawn - // to try in order. + // to try in order. Asking for more picks draws twice as many, from a + // shortlist at least as long. shortlist = 25 perArtist = 2 drawn = 8 @@ -116,8 +117,8 @@ func score(cands []candidate, p Profile, lt LongTerm, explore float64, deepCut b } // diverse keeps, in order, the best perArtist songs of each artist, up to -// shortlist songs. -func diverse(scored []candidate) []candidate { +// n songs. +func diverse(scored []candidate, n int) []candidate { var out []candidate count := map[string]int{} for _, c := range scored { @@ -125,7 +126,7 @@ func diverse(scored []candidate) []candidate { count[c.artist]++ out = append(out, c) } - if len(out) == shortlist { + if len(out) == n { break } } @@ -136,7 +137,7 @@ func diverse(scored []candidate) []candidate { // probability that grows with its score (softmax at temp), without // replacement. rand returns a number in [0, n). func draw(scored []candidate, n int, temp float64, rand func(int) int) []candidate { - pool := diverse(scored) + pool := diverse(scored, max(shortlist, n)) var out []candidate for len(out) < n && len(pool) > 0 { top := pool[0].score diff --git a/server/internal/httpapi/quickpicks.go b/server/internal/httpapi/quickpicks.go index 0a43dda..c86c9eb 100644 --- a/server/internal/httpapi/quickpicks.go +++ b/server/internal/httpapi/quickpicks.go @@ -8,7 +8,6 @@ import ( "sync" "github.com/madeofpendletonwool/syncphony/server/internal/provider" - "github.com/madeofpendletonwool/syncphony/server/internal/rooms" "github.com/madeofpendletonwool/syncphony/server/internal/store" "github.com/madeofpendletonwool/syncphony/server/internal/suggest" ) @@ -33,19 +32,7 @@ func (s *Server) GetQueueItemSimilar(ctx context.Context, req GetQueueItemSimila // A Finder isn't safe for concurrent use, so each link gets one. f := suggest.NewFinder(s.Links, rand.IntN) defer f.Close() - if !recommends { - // Services that only search have more by the same artist. - sess, ok := f.Open(ctx, l.ID) - if !ok { - return nil - } - return f.ByArtist(ctx, sess, l, seed) - } - rec, sess, ok := f.Recommender(ctx, l.ID) - if !ok { - return nil - } - return f.Similar(ctx, rec, sess, l, seed, rooms.AdventureSimilar) + return f.Like(ctx, l, seed, recommends) }) if err != nil { return nil, err diff --git a/server/internal/httpapi/suggestions.go b/server/internal/httpapi/suggestions.go index 9b651de..cbfb6f5 100644 --- a/server/internal/httpapi/suggestions.go +++ b/server/internal/httpapi/suggestions.go @@ -5,6 +5,7 @@ package httpapi import ( "context" + "github.com/madeofpendletonwool/syncphony/server/internal/dj" "github.com/madeofpendletonwool/syncphony/server/internal/suggest" ) @@ -30,7 +31,7 @@ func (s *Server) GetSuggestions(ctx context.Context, req GetSuggestionsRequestOb } out := Suggestions{Scope: SuggestionsScope(q.Scope), Items: make([]Suggestion, len(ss))} for i, sg := range ss { - seed := suggest.TrackOf(sg.Seed) + seed := dj.TrackOf(sg.Seed) because := SuggestionSeed{ItemId: sg.Seed.ID, Title: seed.Title, UserId: sg.Seed.AddedBy} if len(seed.Artists) > 0 { because.Artist = ptr(seed.Artists[0].Name) diff --git a/server/internal/suggest/dj_test.go b/server/internal/suggest/dj_test.go new file mode 100644 index 0000000..33774bf --- /dev/null +++ b/server/internal/suggest/dj_test.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package suggest_test + +import ( + "context" + "fmt" + "slices" + "testing" + + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/provider/fake" + "github.com/madeofpendletonwool/syncphony/server/internal/store" + "github.com/madeofpendletonwool/syncphony/server/internal/suggest" +) + +// knowledge is a music knowledge source in memory, about the fake's +// artists and one the fake doesn't have. +type knowledge map[string]musicgraph.Artist + +func (knowledge) Name() string { return "test" } + +func (k knowledge) Artist(_ context.Context, a musicgraph.ArtistRef) (musicgraph.Artist, error) { + if out, ok := k[a.Name]; ok { + return out, nil + } + return musicgraph.Artist{}, fmt.Errorf("%s: %w", a.Name, provider.ErrNotFound) +} + +func (knowledge) Track(context.Context, musicgraph.SongRef) (musicgraph.Track, error) { + return musicgraph.Track{}, provider.ErrNotFound +} + +func known(name string, similar []musicgraph.Similar, top ...string) musicgraph.Artist { + a := musicgraph.Artist{Ref: musicgraph.ArtistRef{Name: name}, Similar: similar} + for i, t := range top { + a.Top = append(a.Top, musicgraph.Song{SongRef: musicgraph.SongRef{Title: t, Artist: a.Ref}, Score: 1 - 0.2*float64(i)}) + } + return a +} + +func like(name string, score float64) musicgraph.Similar { + return musicgraph.Similar{Artist: musicgraph.ArtistRef{Name: name}, Score: score, Sources: []string{"test"}} +} + +// The Test Patterns are most like Ghost Band, whom no service has, then +// Null Island; Null Island is like Sine Language. +var library = knowledge{ + "The Test Patterns": known("The Test Patterns", []musicgraph.Similar{like("Ghost Band", 0.95), like("Null Island", 0.9)}, + "SMPTE", "Left Channel", "Reference Tone"), + "Ghost Band": known("Ghost Band", nil, "Ghost Song"), + "Null Island": known("Null Island", []musicgraph.Similar{like("Sine Language", 0.8)}, "Greenwich", "Latitude", "Weather Buoy"), + "Sine Language": known("Sine Language", nil, "Concert Pitch", "Tuning Fork"), +} + +func (e *env) withGraph(k knowledge) { + e.sg.Graph = musicgraph.New(e.db, musicgraph.Options{Sources: []musicgraph.Source{k}}) +} + +// artists are the fake's artists of the suggestions' tracks, by ID. +func artistOf(id string) string { + switch { + case id <= "t06": + return "The Test Patterns" + case id <= "t12": + return "Null Island" + } + return "Sine Language" +} + +func TestDJYourVibe(t *testing.T) { + e := newEnv(t, fake.Options{}) + e.withGraph(library) + mine := e.add(e.alice, "t01") // Reference Tone, The Test Patterns + e.play(mine.ID, store.EndFinished) + e.play(e.add(e.bob, "t13").ID, store.EndFinished) // Concert Pitch, Sine Language + + got := e.suggest(e.alice, suggest.ScopeMine, suggest.OriginHistory, 10, false) + // Alice's taste leads to The Test Patterns and on to Null Island + // (Greenwich, t10), which the service's own recommendations don't. + // Bob's Sine Language isn't her vibe. + if !slices.Contains(ids(got), "t10") { + t.Fatalf("suggested %v, want the DJ's Greenwich (t10)", ids(got)) + } + for _, s := range got { + if a := artistOf(s.Track.Ref.ID); a == "Sine Language" { + t.Errorf("alice's vibe has bob's %s (%s)", s.Track.Ref.ID, a) + } + if s.Seed.ID != mine.ID { + t.Errorf("%s is like %s, want alice's t01", s.Track.Ref.ID, s.Seed.TrackID) + } + if s.Track.Ref.LinkID != e.alice.link { + t.Errorf("%s is from link %s, want alice's", s.Track.Ref.ID, s.Track.Ref.LinkID) + } + if s.Track.Ref.ID == "t01" || s.Track.Ref.ID == "t13" { + t.Errorf("suggested %s, which just played", s.Track.Ref.ID) + } + } +} + +func TestDJGroupVibe(t *testing.T) { + e := newEnv(t, fake.Options{}) + e.withGraph(library) + alices := e.add(e.alice, "t01") + e.play(alices.ID, store.EndFinished) + bobs := e.add(e.bob, "t16") // Fundamental, Sine Language's other album + e.play(bobs.ID, store.EndFinished) + + got := e.suggest(e.alice, suggest.ScopeGroup, suggest.OriginHistory, 10, false) + byArtist := map[string]bool{} + for _, s := range got { + a := artistOf(s.Track.Ref.ID) + byArtist[a] = true + // Each is credited to the member's song it leads from. + want := alices.ID + if a == "Sine Language" { + want = bobs.ID + } + if s.Seed.ID != want { + t.Errorf("%s (%s) is like %s", s.Track.Ref.ID, a, s.Seed.TrackID) + } + } + if !byArtist["Sine Language"] || !byArtist["Null Island"] { + t.Errorf("suggested %v, want both members' vibes", ids(got)) + } +} + +func TestDJQueueVibe(t *testing.T) { + e := newEnv(t, fake.Options{}) + e.withGraph(library) + e.play(e.add(e.alice, "t01").ID, store.EndFinished) + waiting := e.add(e.bob, "t07") // Latitude, Null Island + + // The queued change of vibe is the vibe: Null Island and what's like + // them, not The Test Patterns. + got := e.suggest(e.alice, suggest.ScopeGroup, suggest.OriginQueue, 10, false) + if len(got) == 0 { + t.Fatal("suggested nothing") + } + for _, s := range got { + if a := artistOf(s.Track.Ref.ID); a == "The Test Patterns" || s.Seed.ID != waiting.ID { + t.Errorf("%s (%s) is like %s, want only like bob's waiting t07", s.Track.Ref.ID, a, s.Seed.TrackID) + } + } + + // Alice queued nothing: her own songs that played stand in. + for _, s := range e.suggest(e.alice, suggest.ScopeMine, suggest.OriginQueue, 10, false) { + if s.Seed.AddedBy != e.alice.ID { + t.Errorf("alice's queue vibe has %s, like bob's %s", s.Track.Ref.ID, s.Seed.TrackID) + } + } +} + +// A room the DJ turned an artist away from isn't suggested them. +func TestDJTurnsAway(t *testing.T) { + e := newEnv(t, fake.Options{}) + e.withGraph(library) + e.play(e.add(e.alice, "t01").ID, store.EndFinished) + e.play(e.add(e.bob, "t07").ID, store.EndSkipped) + for _, s := range e.suggest(e.alice, suggest.ScopeGroup, suggest.OriginHistory, 10, false) { + if a := artistOf(s.Track.Ref.ID); a == "Null Island" { + t.Errorf("suggested %s by %s, whom the room just skipped", s.Track.Ref.ID, a) + } + } +} + +// When the graph knows nothing near the vibe, the services' own +// recommendations stand in, as before. +func TestDJFallsBack(t *testing.T) { + e := newEnv(t, fake.Options{}) + e.withGraph(knowledge{}) + e.play(e.add(e.alice, "t01").ID, store.EndFinished) + got := e.suggest(e.alice, suggest.ScopeMine, suggest.OriginHistory, 5, false) + if want := []string{"t02", "t03", "t04", "t05", "t06"}; !slices.Equal(ids(got), want) { + t.Fatalf("suggested %v, want the service's %v", ids(got), want) + } +} + +// A service that only searches gets the DJ's suggestions too. +func TestDJSearchOnlyService(t *testing.T) { + e := newEnv(t, fake.Options{ID: "spotty", Name: "Spotty", NoRecommendations: true}) + e.withGraph(library) + e.play(e.add(e.alice, "t01").ID, store.EndFinished) + if got := ids(e.suggest(e.alice, suggest.ScopeMine, suggest.OriginHistory, 10, false)); !slices.Contains(got, "t10") { + t.Fatalf("suggested %v, want the DJ's Greenwich (t10)", got) + } +} diff --git a/server/internal/suggest/find.go b/server/internal/suggest/find.go index 2a0dfe7..b69073a 100644 --- a/server/internal/suggest/find.go +++ b/server/internal/suggest/find.go @@ -4,10 +4,10 @@ package suggest import ( "context" - "encoding/json" "log/slog" "strings" + "github.com/madeofpendletonwool/syncphony/server/internal/dj" "github.com/madeofpendletonwool/syncphony/server/internal/match" "github.com/madeofpendletonwool/syncphony/server/internal/provider" "github.com/madeofpendletonwool/syncphony/server/internal/rooms" @@ -34,14 +34,7 @@ type Seed struct { } // SeedOf returns it as a seed. -func SeedOf(it store.QueueItem) Seed { return Seed{Item: it, Track: TrackOf(it)} } - -// TrackOf returns the track an item snapshotted when it was queued. -func TrackOf(it store.QueueItem) provider.Track { - var t provider.Track - _ = json.Unmarshal([]byte(it.Metadata), &t) - return t -} +func SeedOf(it store.QueueItem) Seed { return Seed{Item: it, Track: dj.TrackOf(it)} } // Keys are the ways a song is recognized: on its service, by ISRC on any // service, and by title and artist. @@ -56,12 +49,13 @@ func Keys(t provider.Track) []string { return out } -// ArtistKey is a song's first artist, for comparing. +// ArtistKey is a song's first artist, compared as the DJ does +// (dj.ArtistKey). func ArtistKey(t provider.Track) string { if len(t.Artists) == 0 { return "" } - return strings.ToLower(strings.TrimSpace(t.Artists[0].Name)) + return dj.ArtistKey(t.Artists[0].Name) } // Seen is a set of songs, by Keys. @@ -69,7 +63,7 @@ type Seen map[string]bool // Item adds a queued song, and the stand-in it played through, if any. func (s Seen) Item(it store.QueueItem) { - t := TrackOf(it) + t := dj.TrackOf(it) t.Ref = provider.TrackRef{Provider: it.Provider, ID: it.TrackID} s.Track(t) if it.ViaLinkID.Valid { @@ -147,6 +141,23 @@ func (f *Finder) Recommender(ctx context.Context, linkID string) (provider.Recom return r, sess, ok } +// Like asks one service for songs like sd's, most alike first: similar +// songs from one that recommends, else more by sd's artist. +func (f *Finder) Like(ctx context.Context, l store.ServiceLink, sd Seed, recommends bool) []provider.Track { + if !recommends { + sess, ok := f.Open(ctx, l.ID) + if !ok { + return nil + } + return f.ByArtist(ctx, sess, l, sd) + } + rec, sess, ok := f.Recommender(ctx, l.ID) + if !ok { + return nil + } + return f.Similar(ctx, rec, sess, l, sd, rooms.AdventureSimilar) +} + // Similar asks one service for songs like sd's, most alike first. With // rooms.AdventureDiscovery, they're by artists like sd's. func (f *Finder) Similar(ctx context.Context, rec provider.Recommender, sess provider.Session, l store.ServiceLink, sd Seed, adventure string) []provider.Track { diff --git a/server/internal/suggest/suggest.go b/server/internal/suggest/suggest.go index 334154b..0d51a88 100644 --- a/server/internal/suggest/suggest.go +++ b/server/internal/suggest/suggest.go @@ -3,14 +3,17 @@ // Package suggest finds songs to keep a room's vibe going: songs like the // ones the room has been playing and has queued. Search shows them before // you type, as "your vibe" (like your own songs) and "group vibe" (like -// everyone's). A song the room skipped doesn't seed, and turns its artist -// away, unless the room finished or queued one of theirs. Autopilot finds -// its songs with the same Finder. See docs/adr/0010-suggestions.md. +// everyone's). See docs/adr/0010-suggestions.md. // -// Suggestions only come from links the asker can add from, so every one -// can be queued with a tap. Services that recommend (provider.Recommender) -// give songs like each seed; services that only search give more by the -// seed's artist. +// The DJ (internal/dj, ADR 0012) picks them, as it does autopilot's songs: +// from your taste or the room's, scored the same way. When there's no +// music graph, or it knows nothing near the room's taste, the services' +// own recommendations stand in, through the Finder autopilot falls back +// on too: services that recommend (provider.Recommender) give songs like +// each seed; services that only search give more by the seed's artist. +// +// Either way, suggestions only come from links the asker can add from, so +// every one can be queued with a tap. package suggest import ( @@ -67,7 +70,7 @@ const ( // historyReach is how many of the room's plays are read: the songs not // to suggest, and where played seeds come from. historyReach = 200 - // maxSeeds is how many songs one list is built from. + // maxSeeds is how many songs one list is built from, without the DJ. maxSeeds = 6 // linksPerSeed is how many services are asked about each seed. linksPerSeed = 2 @@ -75,6 +78,11 @@ const ( window = SimilarTop * 2 // MaxLimit is the most suggestions one list has. MaxLimit = 50 + // explore is how far the DJ strays for suggestions: near the vibe, as + // autopilot's "similar" (rooms.Autopilot). + explore = 25 + // autopilotReach is how many of autopilot's own songs the DJ reads. + autopilotReach = 50 ) // Suggestion is a song to queue, and the song it's like. @@ -100,6 +108,9 @@ type Service struct { db *store.Store rooms *rooms.Service links UsableLinks + // Graph, if set, is the music knowledge the DJ suggests from (ADR + // 0012), before falling back on the services' own recommendations. + Graph dj.Graph // TTL is how long a list is reused while the queue doesn't change. // Default 2m. TTL time.Duration @@ -209,21 +220,26 @@ func (s *Service) build(ctx context.Context, q Query, snap rooms.QueueSnapshot, if len(seeds) == 0 { return nil, nil } - turned := TurnedAway(snap, history, s.Now()) links, err := s.links.Usable(ctx, q.UserID) if err != nil { return nil, err } - var recommend, search []store.ServiceLink + var recommend, search, searchable []store.ServiceLink for _, l := range links { p, err := s.links.Provider(l.Provider) + if err != nil { + continue + } + caps := p.Info().Capabilities switch { - case err != nil: - case p.Info().Capabilities.Recommendations: + case caps.Recommendations: recommend = append(recommend, l) - case p.Info().Capabilities.CanSearch(provider.KindTrack): + case caps.CanSearch(provider.KindTrack): search = append(search, l) } + if caps.CanSearch(provider.KindTrack) { + searchable = append(searchable, l) + } } if len(recommend)+len(search) == 0 { return nil, nil @@ -231,6 +247,10 @@ func (s *Service) build(ctx context.Context, q Query, snap rooms.QueueSnapshot, f := NewFinder(s.links, s.Rand) defer f.Close() + if out, err := s.fromDJ(ctx, q, snap, history, searchable, f, seen); err != nil || len(out) > 0 { + return out, err + } + turned := TurnedAway(snap, history, s.Now()) perSeed := (q.Limit + len(seeds) - 1) / len(seeds) lists := make([][]Suggestion, len(seeds)) for i, sd := range seeds { @@ -239,19 +259,10 @@ func (s *Service) build(ctx context.Context, q Query, snap rooms.QueueSnapshot, } taken := Seen{} for _, l := range linksFor(sd, recommend, search) { - var cands []provider.Track - if slices.ContainsFunc(recommend, func(r store.ServiceLink) bool { return r.ID == l.ID }) { - rec, sess, ok := f.Recommender(ctx, l.ID) - if !ok { - continue - } - cands = f.Similar(ctx, rec, sess, l, sd, rooms.AdventureSimilar) - } else if sess, ok := f.Open(ctx, l.ID); ok { - cands = f.ByArtist(ctx, sess, l, sd) - } + recommends := slices.ContainsFunc(recommend, func(r store.ServiceLink) bool { return r.ID == l.ID }) var fresh []provider.Track - for _, t := range cands { - if !seen.Has(t) && !taken.Has(t) && !turned[djArtist(t)] { + for _, t := range f.Like(ctx, l, sd, recommends) { + if !seen.Has(t) && !taken.Has(t) && !turned[ArtistKey(t)] { taken.Track(t) fresh = append(fresh, t) } @@ -287,6 +298,48 @@ func (s *Service) build(ctx context.Context, q Query, snap rooms.QueueSnapshot, return out, nil } +// fromDJ asks the DJ for suggestions from the music graph, found on the +// asker's links that search. Each is credited to the member's song it's +// like; a pick only autopilot's songs led to has none, and is left out. +// It's empty if there's no graph, or it knows nothing near the vibe. +func (s *Service) fromDJ(ctx context.Context, q Query, snap rooms.QueueSnapshot, history []store.ListHistoryRow, links []store.ServiceLink, f *Finder, seen Seen) ([]Suggestion, error) { + if s.Graph == nil || len(links) == 0 { + return nil, nil + } + in := dj.Input{History: history, Upcoming: snap.Items, Hearts: map[string]int{}, Now: s.Now()} + var err error + if in.Mine, err = s.db.ListAutopilot(ctx, store.ListAutopilotParams{RoomID: q.RoomID, Limit: autopilotReach}); err != nil { + return nil, err + } + if len(history) > 0 { + counts, err := s.db.HeartCountsSince(ctx, store.HeartCountsSinceParams{RoomID: q.RoomID, Since: history[len(history)-1].PlayHistory.StartedAt}) + if err != nil { + return nil, err + } + for _, c := range counts { + in.Hearts[c.QueueItemID] = int(c.Hearts) + } + } + for _, it := range in.Mine { + seen.Item(it) // removed ones too: someone didn't want that song + } + r := dj.Request{ + Input: in, Explore: explore, Links: links, Sessions: f, Want: q.Limit, + Fresh: func(t provider.Track) bool { return !seen.Has(t) }, Queued: q.Origin == OriginQueue, + } + if q.Scope == ScopeMine { + r.Member = q.UserID + } + engine := &dj.Engine{Graph: s.Graph, Rand: s.Rand} + var out []Suggestion + for _, p := range engine.Choose(ctx, r) { + if p.Seed.ID != "" { + out = append(out, Suggestion{Track: p.Track, Seed: p.Seed}) + } + } + return out, nil +} + // sample picks up to n of the candidates near the top of the list, in // their order, at random. func (s *Service) sample(cands []provider.Track, n int) []provider.Track { @@ -344,12 +397,11 @@ func Seeds(scope Scope, origin Origin, userID string, snap rooms.QueueSnapshot, return pickSeeds(scope, userID, all) } -// TurnedAway returns the artists (dj.ArtistKey) the room turned away: as -// the DJ does (dj.Profile, ADR 0012), those whose decayed signal, skips -// against plays, hearts and queued songs, is below the DJ's threshold. -// A skip long ago, faded to almost nothing, doesn't turn an artist away -// (MAD-762). Songs by them aren't suggested. MAD-759 moves the rest of -// suggestions onto the DJ. +// TurnedAway returns the artists (dj.ArtistKey) the room turned away, for +// suggestions without the DJ: as the DJ does (dj.Profile, ADR 0012), those +// whose decayed signal, skips against plays, hearts and queued songs, is +// below the DJ's threshold. A skip long ago, faded to almost nothing, +// doesn't turn an artist away (MAD-762). func TurnedAway(snap rooms.QueueSnapshot, history []store.ListHistoryRow, now time.Time) map[string]bool { return dj.NewProfile(dj.Input{History: history, Upcoming: snap.Items, Now: now}).Avoid } @@ -410,11 +462,3 @@ func linksFor(sd Seed, recommend, search []store.ServiceLink) []store.ServiceLin out := append(first(recommend), first(search)...) return out[:min(len(out), linksPerSeed)] } - -// djArtist is a track's artist as the DJ keys it. -func djArtist(t provider.Track) string { - if len(t.Artists) == 0 { - return "" - } - return dj.ArtistKey(t.Artists[0].Name) -}