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
453 changes: 447 additions & 6 deletions api/openapi.yaml

Large diffs are not rendered by default.

36 changes: 33 additions & 3 deletions docs/adr/0017-personal-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

- **Status:** accepted
- **Date:** 2026-10-13
- **Issues:** MAD-814 (Phase 12), stage 1: MAD-815..819, stage 2: MAD-820, 821, stage 3: MAD-822, 823
- **Issues:** MAD-814 (Phase 12), stage 1: MAD-815..819, stage 2: MAD-820, 821, stage 3: MAD-822, 823, stage 4: MAD-824..826

## Context

Phase 12 adds a way to find music you **don't have yet**, the way Google Play Music's "new releases for you" did. Everything stays on the server. You hear a 30-second preview, follow a link out to YouTube for the whole song, and later (stage 4) keep what you like on a Want list until it turns up in your library.
Phase 12 adds a way to find music you **don't have yet**, the way Google Play Music's "new releases for you" did. Everything stays on the server. You hear a 30-second preview, follow a link out to YouTube for the whole song, and keep what you like on a Want list until it turns up in your library.

The obvious seed is your library. It's the wrong one. A Navidrome library holds plenty of music its owner doesn't love, so recommending from it recommends the wrong things. Syncphony's own history is still short, too.

Expand Down Expand Up @@ -81,9 +81,39 @@ A release card shows its cover (Deezer's, else the Cover Art Archive's through `

The unread dot on the Discover tab, for releases you haven't seen yet, isn't built. Knowing what's unseen would mean working out every set's Fresh feed for the tab bar.

### The Want list

A thumbs-up on a song, or **Want** on a release, puts it on the Want list (`want_items`), noting the set whose feed it came from (MAD-824). Wanted things are left out of every feed and Fresh feed. Syncphony never gets them for you. You follow the YouTube link, add what you like to your Navidrome, and Syncphony notices.

- **Automatic got-it.** `library.Service` calls its `Built` hook after every index build of a link, with the link's user. That includes a build skipped because Navidrome hadn't rescanned, since something wanted since then may already be there. `discover.CheckWants` then matches each wanted thing against the user's index: a song with `Has` (ISRC or MBID first, then artist and title), a release with `HasRelease` (artist and title, so a deluxe edition counts). A match is marked `got`, `got_by = auto`, with `got_at`. Discover then says "3 wanted things landed in your library" until you open the Want list (`got_seen`). Marking something got yourself (`got_by = manual`) doesn't count as landing.
- **The screen** (`/discover/want`) groups by artist, with Wanted, Got and Dismissed. A wanted song plays its preview and links to YouTube, and anything can be marked got or dismissed. Got releases link to the album in Syncphony (the linked-names lookup, ADR 0013 and MAD-837), and got songs to a song search, to queue or play right away. There are no store links. Wanting a dismissed thing again brings it back.
- **What a set yields.** The set list counts what each set's feeds led you to want, and how much of that you got (`wanted`, `got` on `listDiscoverySets`). That shows which sets pay off. The counts follow `want_items.set_id`, which is cleared when a set is deleted.

Migration 00032 adds `want_items.cover`, `got_by` and `got_seen`.

### Sets that learn

Thumbs still never reach a room (MAD-825). Within discovery, they now do three things:

- **Growing.** An artist you thumbed up twice from a set's feed is offered for the set ("You liked 2 songs by Failure. Add them?"). A set with **Grow by itself** on (`grow_auto`, off by default) adds them at the second thumbs-up, and the thumb's answer says who joined (`grew`). Either way they're stored with `added_from = thumb`. Offers you turn down are kept in `discovery_dismissed` and aren't made again.
- **Thumbs-down.** Each thumbs-down from a set's feed lowers that artist's songs in that feed by 0.6×, so three take them to about a fifth. That happens when the feed is next built, within 10 minutes or on refresh, not on every thumb, because a rebuild mid-scroll would restart the pages. The song turned down still leaves every feed at once. Three thumbs-down for one artist, in any feeds, offers to block them.
- **Not for me** is the global block list. It and the library's status now live on a Discover settings screen (`/discover/settings`), where you can unblock.
- **Starred import** is a one-time, opt-in pick list. `GET /me/discover/starred` lists the artists you starred on your own Navidrome links, and the artists of the albums and songs you starred, through a new optional `provider.Starrer` (Navidrome's `getStarred2`). Starred artists come first, then by how much of theirs you starred. Only the ones you check are added (`importDiscoverySetArtists`, `added_from = starred`). It's never automatic: starring something doesn't make it a set's taste.

`importDiscoverySetArtists` adds the artists you picked at once, from a suggestion, your stars or a room. Ones in the set already are skipped. More than the set has room for is `too_many`, and adds none.

### A room's taste as a source

A set can draw on a room you can open (`rooms.CanEnter`: open rooms, yours, or ones you're a member of) in two ways (MAD-826):

- **Snapshot.** `GET /me/discover/room-taste` lists a room's favorite artists, or one past night's (`nightId`), optionally from only your own picks (`mine`). You check the ones to keep, into a set or a new one, with `added_from = room`. A room's taste is its long-term taste as the DJ keeps it (`dj.Peek`) merged with a profile of its latest 200 plays (`dj.NewProfile`), whichever likes an artist more. Artists the room turned away lately are left out. A night's taste is that night's plays alone (`dj.NightInput`). With `mine`, the long-term rows are narrowed to your own (`dj_affinities.member`), and the profile to your share of each artist (`ByMember`).
- **Live.** A set can link to one room (`discovery_sets.room_id`) with a weight: low 0.3, medium 0.55 or high 0.85. Building the feed then walks out from the room's 12 favorite artists that aren't seeds, as from seeds that pull that much less, times how much the room likes each one. As the room's taste moves, so does the feed, at its next build. A set linked to a room can have no seeds of its own. Fresh feeds and maps still follow only the seeds.

Both are read only. `dj.Peek` reads the DJ's stored rows without folding in nights that ended since, which `Memory.Load` would do, so discovery never writes a room's DJ memory. Room skips don't affect a set. A room you can no longer open is dropped from the walk, and `room_id` is cleared if the room is deleted.

### 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.
Discovery thumbs and blocks never reach a room's DJ (`dj_affinity`), and a room's skips never touch a set. Reading a room's taste for a set doesn't change it either.

## Consequences

Expand Down
15 changes: 13 additions & 2 deletions server/cmd/syncphony/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,13 +382,24 @@ func run() error {
}
// Personal discovery (ADR 0017): seed sets, minus what's in your library.
lib := library.New(db, a.links)
go lib.Run(ctx)
api.Library = lib
api.Discover = discover.New(db)
api.Discover.Library = lib
api.Discover.Library, api.Discover.Stars = lib, lib
// Each index built marks what its user wanted and now has as got
// (MAD-824).
lib.Built = func(ctx context.Context, userID string) {
if _, err := api.Discover.CheckWants(ctx, userID); err != nil && ctx.Err() == nil {
slog.Warn("discover: checking the Want list", "user", userID, "err", err)
}
}
go lib.Run(ctx)
// A room's taste as a set's source (MAD-826): read only.
roomTaste := &discover.RoomSource{DB: db, CanEnter: roomSvc.CanEnter}
if len(graph.Sources()) > 0 {
api.Discover.Graph = graph
roomTaste.Graph = graph
}
api.Discover.Rooms = roomTaste
if deezer != nil {
api.Deezer, api.Discover.Deezer = deezer, deezer
} else {
Expand Down
95 changes: 85 additions & 10 deletions server/internal/discover/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ type Service struct {
// Releases finds artists' recent releases, for Fresh feeds. Nil
// makes every Fresh feed empty.
Releases Releases
// Stars lists what users starred on their own links, to pick seeds
// from. Nil lists nothing.
Stars Stars
// Rooms reads rooms' taste, for sets made from one or linked to one.
// Nil means no room can be a source.
Rooms RoomTaste
// Now is the clock. Default store.Now.
Now func() time.Time
// BuildFor bounds building a feed; what's found by then is the feed.
Expand Down Expand Up @@ -237,6 +243,12 @@ type Changes struct {
Explore *int
Mode *string
FreshDays *int
// GrowAuto is whether artists thumbed up twice join by themselves.
GrowAuto *bool
// RoomID links the set to a room whose taste it mixes in; "" unlinks
// it. RoomWeight is how much: RoomLow, RoomMedium or RoomHigh.
RoomID *string
RoomWeight *string
}

// Update renames a set, or changes its explore, mode or Fresh window.
Expand All @@ -246,7 +258,8 @@ func (s *Service) Update(ctx context.Context, userID, setID string, c Changes) (
return Set{}, err
}
p := store.UpdateDiscoverySetParams{
ID: setID, UserID: userID, Name: set.Name, Explore: set.Explore, Mode: set.Mode, FreshDays: set.FreshDays, UpdatedAt: s.Now(),
ID: setID, UserID: userID, Name: set.Name, Explore: set.Explore, Mode: set.Mode, FreshDays: set.FreshDays,
GrowAuto: set.GrowAuto, RoomID: set.RoomID, RoomWeight: set.RoomWeight, UpdatedAt: s.Now(),
}
if c.Name != nil {
if p.Name, err = cleanName(*c.Name); err != nil {
Expand All @@ -271,6 +284,29 @@ func (s *Service) Update(ctx context.Context, userID, setID string, c Changes) (
}
p.FreshDays = int64(*c.FreshDays)
}
if c.GrowAuto != nil {
p.GrowAuto = 0
if *c.GrowAuto {
p.GrowAuto = 1
}
}
if c.RoomWeight != nil {
if !slices.Contains([]string{RoomLow, RoomMedium, RoomHigh}, *c.RoomWeight) {
return Set{}, &InvalidInputError{"a room's weight is low, medium or high"}
}
p.RoomWeight = *c.RoomWeight
}
if c.RoomID != nil {
p.RoomID = sql.NullString{String: *c.RoomID, Valid: *c.RoomID != ""}
if p.RoomID.Valid {
if s.Rooms == nil {
return Set{}, &InvalidInputError{"rooms can't be a set's source on this server"}
}
if err := s.Rooms.MayRead(ctx, userID, *c.RoomID); err != nil {
return Set{}, err
}
}
}
if err := s.db.UpdateDiscoverySet(ctx, p); err != nil {
return Set{}, err
}
Expand Down Expand Up @@ -310,13 +346,17 @@ func (s *Service) AddArtist(ctx context.Context, userID, setID string, a Seed) (
}

func (s *Service) addArtist(ctx context.Context, setID string, a Seed, from string) error {
return s.addArtistWith(ctx, s.db.Queries, setID, a, from)
}

func (s *Service) addArtistWith(ctx context.Context, q *store.Queries, setID string, a Seed, from string) error {
name := strings.Join(strings.Fields(a.Name), " ")
key := ArtistKey(name, a.MBID)
if name == "" || key == "" || utf8.RuneCountInString(name) > MaxName*2 {
return &InvalidInputError{"an artist's name is needed"}
}
mbid := strings.ToLower(strings.TrimSpace(a.MBID))
return s.db.AddDiscoverySetArtist(ctx, store.AddDiscoverySetArtistParams{
return q.AddDiscoverySetArtist(ctx, store.AddDiscoverySetArtistParams{
SetID: setID, ArtistKey: key, Name: name, Mbid: sql.NullString{String: mbid, Valid: mbid != ""},
AddedFrom: from, AddedAt: s.Now(),
})
Expand Down Expand Up @@ -348,26 +388,61 @@ func (s *Service) touch(ctx context.Context, setID string) error {
// Song is a song given a thumb.
type Song struct {
Title, Artist string
MBID, ISRC string
DeezerID int64
// Album and Cover are its album's title and Deezer cover, if known,
// for the Want list.
Album, Cover string
MBID, ISRC string
DeezerID int64
}

// Thumbed is what a thumb did besides being kept.
type Thumbed struct {
// Want is the song on the Want list, for a thumbs-up.
Want *store.WantItem
// Grew is the artist who joined the set, if the set grows by itself
// and this was their second thumbs-up in it.
Grew string
}

// Thumb records a thumbs-up or -down on a song, from a set's feed if
// setID is given. A thumbs-down hides the song from every one of the
// user's feeds, for good; a later thumbs-up replaces it.
func (s *Service) Thumb(ctx context.Context, userID, setID, kind string, song Song) error {
// user's feeds, for good, and lowers its artist in that set's feed; a
// later thumbs-up replaces it. A thumbs-up puts the song on the Want
// list, and may grow the set (see Suggestions).
func (s *Service) Thumb(ctx context.Context, userID, setID, kind string, song Song) (Thumbed, error) {
if kind != ThumbUp && kind != ThumbDown {
return &InvalidInputError{"a thumb is up or down"}
return Thumbed{}, &InvalidInputError{"a thumb is up or down"}
}
key := SongKey(song.Artist, song.Title)
if strings.TrimSpace(song.Title) == "" || strings.TrimSpace(song.Artist) == "" || strings.HasPrefix(key, "\x00") {
return &InvalidInputError{"a song's title and artist are needed"}
return Thumbed{}, &InvalidInputError{"a song's title and artist are needed"}
}
var set Set
if setID != "" {
if _, err := s.Get(ctx, userID, setID); err != nil {
return err
var err error
if set, err = s.Get(ctx, userID, setID); err != nil {
return Thumbed{}, err
}
}
if err := s.putThumb(ctx, userID, setID, kind, key, song); err != nil {
return Thumbed{}, err
}
var out Thumbed
if kind != ThumbUp {
return out, nil
}
w, err := s.AddWant(ctx, userID, wantFromSong(setID, song))
if err != nil {
return out, err
}
out.Want = &w
if setID != "" && set.GrowAuto != 0 {
out.Grew, err = s.growBy(ctx, userID, set, song.Artist)
}
return out, err
}

func (s *Service) putThumb(ctx context.Context, userID, setID, kind, key string, song Song) error {
return s.db.PutDiscoveryFeedback(ctx, store.PutDiscoveryFeedbackParams{
ID: store.NewID(), UserID: userID, SetID: sql.NullString{String: setID, Valid: setID != ""}, Kind: kind,
SongKey: key, Title: song.Title, Artist: song.Artist,
Expand Down
4 changes: 2 additions & 2 deletions server/internal/discover/discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ func TestThumbsDownIsForGood(t *testing.T) {
other := e.set(t, "Duster")
f, _ := e.svc.Feed(t.Context(), e.alice, set.ID, "", false)
down := f.Items[0]
if err := e.svc.Thumb(t.Context(), e.alice, set.ID, discover.ThumbDown, discover.Song{Title: down.Title, Artist: down.Artist}); err != nil {
if _, err := e.svc.Thumb(t.Context(), e.alice, set.ID, discover.ThumbDown, discover.Song{Title: down.Title, Artist: down.Artist}); err != nil {
t.Fatal(err)
}
for _, id := range []string{set.ID, other.ID} {
Expand Down Expand Up @@ -373,7 +373,7 @@ func TestSets(t *testing.T) {
if sets, _ := e.svc.Sets(ctx, e.alice); len(sets) != 0 {
t.Errorf("sets after deleting: %+v", sets)
}
if err := e.svc.Thumb(ctx, e.alice, "", "sideways", discover.Song{Title: "x", Artist: "y"}); !isInvalid(err) {
if _, err := e.svc.Thumb(ctx, e.alice, "", "sideways", discover.Song{Title: "x", Artist: "y"}); !isInvalid(err) {
t.Errorf("a sideways thumb: %v", err)
}
}
Expand Down
12 changes: 10 additions & 2 deletions server/internal/discover/feed.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ func (n *node) because(names map[string]string) []string {
// pages, best first and taking turns between the seeds, without songs the
// user has or turned down, within BuildFor.
func (s *Service) build(ctx context.Context, userID string, set Set) [][]Item {
if s.Graph == nil || len(set.Artists) == 0 {
if s.Graph == nil || (len(set.Artists) == 0 && !set.RoomID.Valid) {
return nil
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.BuildFor)
Expand All @@ -275,6 +275,12 @@ func (s *Service) build(ctx context.Context, userID string, set Set) [][]Item {
keep = func(Item) bool { return true }
}

// Thumbs-down from this set's feed lower their artists in it.
var setDowns map[string]int
if tc, err := s.thumbCounts(ctx, userID, set.ID); err == nil {
setDowns = tc.setDowns
}

lk := &looker{g: s.Graph, ctx: ctx, budget: maxFetches}
lookup := func(ref musicgraph.ArtistRef) (musicgraph.Artist, bool) { return lk.get(ref, true) }

Expand Down Expand Up @@ -309,6 +315,8 @@ func (s *Service) build(ctx context.Context, userID string, set Set) [][]Item {
reach(sim.Artist, k, sim.Score, 1)
}
}
// A linked room's favorite artists pull too, less than the seeds.
walkRoom(s.roomSeeds(ctx, userID, set), lookup, names, reach)
for _, n := range seeds {
delete(nodes, library.ArtistKey(n.ref.Name)) // a seed isn't a new artist
}
Expand Down Expand Up @@ -365,7 +373,7 @@ func (s *Service) build(ctx context.Context, userID string, set Set) [][]Item {
if so.Rank > 0 {
pop = 0.6*so.Rank + 0.4*fame
}
it.Score = score(pull, pop, so.Score, explore)
it.Score = score(pull, pop, so.Score, explore) * downWeight(setDowns[library.ArtistKey(artist)])
items = append(items, it)
}
}
Expand Down
Loading
Loading