From 4d48062cf298edea478817ad9caf3ba48d798fba Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Tue, 13 Oct 2026 12:40:40 -0500 Subject: [PATCH 1/2] Personal discovery: recent releases and the Fresh feed API (MAD-822, MAD-823) internal/releases finds an artist's releases from the last 100 days. It merges ListenBrainz's fresh releases (one list, read every 6 hours), MusicBrainz release groups by first-release date, and Deezer's albums. Releases match by artist and title, and each counts from its first edition, so a deluxe reissue isn't new. Answers are cached a day per artist (migration 00030), and every set's seeds and their nearest neighbors are warmed in the background. GET /me/discover/sets/{setId}/fresh ranks a set's recent releases: - The seeds' come first, albums and EPs before singles, and compilations and live albums last. - Releases you have, want, or by blocked artists are left out. - It looks back the set's freshDays (30, 60 or 90). The release-tracks and release-art endpoints serve a release's songs with previews, and Cover Art Archive covers. ADR 0017 is updated. Co-Authored-By: Claude Opus 5.5 --- api/openapi.yaml | 196 +++++- docs/adr/0017-personal-discovery.md | 26 +- server/cmd/syncphony/main.go | 39 +- server/internal/discover/discover.go | 33 +- server/internal/discover/fresh.go | 408 +++++++++++++ server/internal/discover/fresh_test.go | 246 ++++++++ server/internal/httpapi/api.gen.go | 513 +++++++++++++++- server/internal/httpapi/discover.go | 80 ++- server/internal/httpapi/discover_test.go | 82 +++ server/internal/httpapi/server.go | 4 + server/internal/musicbrainz/coverart.go | 13 +- server/internal/musicbrainz/releases.go | 53 ++ server/internal/musicbrainz/releases_test.go | 56 ++ .../testdata/recent-release-groups.json | 4 + server/internal/musicgraph/deezer.go | 39 +- server/internal/musicgraph/deezer_media.go | 3 +- server/internal/musicgraph/http.go | 11 +- server/internal/musicgraph/releases.go | 193 ++++++ server/internal/musicgraph/releases_test.go | 91 +++ server/internal/releases/releases.go | 566 ++++++++++++++++++ server/internal/releases/releases_test.go | 305 ++++++++++ server/internal/store/discovery.sql.gen.go | 51 +- .../store/migrations/00030_fresh_releases.sql | 22 + server/internal/store/models.gen.go | 9 + server/internal/store/queries/discovery.sql | 10 +- server/internal/store/queries/releases.sql | 14 + server/internal/store/releases.sql.gen.go | 71 +++ 27 files changed, 3086 insertions(+), 52 deletions(-) create mode 100644 server/internal/discover/fresh.go create mode 100644 server/internal/discover/fresh_test.go create mode 100644 server/internal/musicbrainz/releases.go create mode 100644 server/internal/musicbrainz/releases_test.go create mode 100644 server/internal/musicbrainz/testdata/recent-release-groups.json create mode 100644 server/internal/musicgraph/releases.go create mode 100644 server/internal/musicgraph/releases_test.go create mode 100644 server/internal/releases/releases.go create mode 100644 server/internal/releases/releases_test.go create mode 100644 server/internal/store/migrations/00030_fresh_releases.sql create mode 100644 server/internal/store/queries/releases.sql create mode 100644 server/internal/store/releases.sql.gen.go diff --git a/api/openapi.yaml b/api/openapi.yaml index c60d075..da8494a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3055,6 +3055,109 @@ paths: default: $ref: "#/components/responses/Error" + /me/discover/sets/{setId}/fresh: + parameters: + - $ref: "#/components/parameters/DiscoverySetId" + get: + operationId: getDiscoveryFresh + summary: A set's Fresh feed + description: | + Releases (albums, EPs, singles) from the set's last `freshDays` by + its seeds and their nearest neighbors, that you don't have or + want, and not by an artist you blocked. The seeds' come first, + then the neighbors' by how near they are; albums and EPs before + singles, and compilations and live albums last. Releases come from + ListenBrainz, MusicBrainz and Deezer, cached a day per artist: + artists not known yet are looked up in the background + (`partial`). + tags: [discover] + responses: + "200": + description: The feed + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoveryFresh" + default: + $ref: "#/components/responses/Error" + + /me/discover/release-tracks: + get: + operationId: listDiscoveryReleaseTracks + summary: A release's songs, with previews + description: | + From Deezer: by `deezerId`, or found by `artist` and `title`. + Empty when Deezer doesn't have it, or is off. + tags: [discover] + parameters: + - name: artist + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 200 + - name: title + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 300 + - name: deezerId + in: query + schema: + type: integer + format: int64 + minimum: 1 + responses: + "200": + description: The songs + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoveryReleaseTracks" + default: + $ref: "#/components/responses/Error" + + /me/discover/release-art/{mbid}: + parameters: + - name: mbid + in: path + required: true + description: A release's MBID (`coverRelease`), or with `group`, a release group's (`mbid`). + schema: + type: string + pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + get: + operationId: getDiscoveryReleaseArt + summary: A release's cover from the Cover Art Archive, through the server + description: For a release Deezer doesn't have. `not_found` if there's none, or MusicBrainz is off. + tags: [discover] + parameters: + - name: group + in: query + description: The MBID is a release group's. + schema: + type: boolean + - name: size + in: query + description: Wanted width in pixels; a hint. + schema: + type: integer + minimum: 0 + maximum: 1200 + responses: + "200": + description: The image + content: + image/*: + schema: + type: string + format: binary + default: + $ref: "#/components/responses/Error" + /me/discover/feedback: post: operationId: postDiscoveryFeedback @@ -8319,13 +8422,13 @@ components: type: string description: | Which feeds a set shows: `deep` cuts (anything new to you), `fresh` - releases (coming), or `both`. + releases (recent albums, EPs and singles), or `both`. enum: [deep, fresh, both] x-enum-varnames: [DiscoveryModeDeep, DiscoveryModeFresh, DiscoveryModeBoth] DiscoverySetSummary: type: object - required: [id, name, explore, mode, artistCount, createdAt, updatedAt] + required: [id, name, explore, mode, freshDays, artistCount, createdAt, updatedAt] properties: id: type: string @@ -8337,6 +8440,8 @@ components: maximum: 100 mode: $ref: "#/components/schemas/DiscoveryMode" + freshDays: + $ref: "#/components/schemas/DiscoveryFreshDays" artistCount: type: integer createdAt: @@ -8348,7 +8453,7 @@ components: DiscoverySet: type: object - required: [id, name, explore, mode, artists, createdAt, updatedAt] + required: [id, name, explore, mode, freshDays, artists, createdAt, updatedAt] properties: id: type: string @@ -8361,6 +8466,8 @@ components: description: How far the feed strays from the seeds, and toward less-known artists. mode: $ref: "#/components/schemas/DiscoveryMode" + freshDays: + $ref: "#/components/schemas/DiscoveryFreshDays" artists: type: array items: @@ -8428,6 +8535,89 @@ components: maximum: 100 mode: $ref: "#/components/schemas/DiscoveryMode" + freshDays: + $ref: "#/components/schemas/DiscoveryFreshDays" + + DiscoveryFreshDays: + type: integer + description: How far back a set's Fresh feed looks, in days. + enum: [30, 60, 90] + x-enum-varnames: [DiscoveryFresh30Days, DiscoveryFresh60Days, DiscoveryFresh90Days] + + DiscoveryRelease: + type: object + description: A recent release near a set's seeds, that you don't have. + required: [key, title, artist, date, kind, sources, because, seed] + properties: + key: + type: string + title: + type: string + artist: + type: string + description: Who it's credited to. + date: + type: string + description: When it first came out, YYYY-MM-DD (or YYYY-MM, if that's all that's known). + kind: + type: string + enum: [album, ep, single, compilation, live] + mbid: + type: string + description: Its MusicBrainz release group. + coverRelease: + type: string + description: A release of it with a cover in the Cover Art Archive, for getDiscoveryReleaseArt. + deezerId: + type: integer + format: int64 + description: The release on Deezer, for its songs. + cover: + type: string + description: Its cover's image ID on Deezer, for getDiscoveryImage (kind `cover`). + sources: + type: array + items: + type: string + because: + type: array + description: The seed artists it's near, nearest first; the seed itself for a seed's release. + items: + type: string + seed: + type: boolean + description: Whether it's a seed artist's. + + DiscoveryFresh: + type: object + required: [items, days, partial, library, noGraph, noReleases] + properties: + items: + type: array + items: + $ref: "#/components/schemas/DiscoveryRelease" + days: + $ref: "#/components/schemas/DiscoveryFreshDays" + partial: + type: boolean + description: Some artists' releases weren't known yet. They're being looked up; load it again in a while. + library: + $ref: "#/components/schemas/DiscoveryLibrary" + noGraph: + type: boolean + description: The server knows nothing of how music relates, so only the seeds' releases show. + noReleases: + type: boolean + description: The server has no source of releases (Deezer, ListenBrainz and MusicBrainz are all off). + + DiscoveryReleaseTracks: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/DiscoveryItem" DiscoveryItem: type: object diff --git a/docs/adr/0017-personal-discovery.md b/docs/adr/0017-personal-discovery.md index 41d8e2c..a7ebd56 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, stage 2: MAD-820, 821 +- **Issues:** MAD-814 (Phase 12), stage 1: MAD-815..819, stage 2: MAD-820, 821, stage 3: MAD-822, 823 ## Context @@ -57,6 +57,30 @@ Top songs from Deezer carry their Deezer ID, album and cover (`musicgraph.SongRe 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. +### Fresh releases + +The **Fresh** feed (MAD-823) is the "new releases for you" part: what your seeds and their neighbors put out lately. `internal/releases` (MAD-822) finds an artist's recent releases from three sources and merges them by artist and title (`library.ReleaseKey`, so a deluxe edition is the same release): + +- **ListenBrainz's fresh releases** (`/1/explore/fresh-releases`, 90 days): every release lately, about 12 MB, read once every 6 hours and kept in memory by artist MBID and name. One request answers for every artist. It carries release group MBIDs, types and Cover Art Archive covers. +- **MusicBrainz release groups** for an artist's MBID, searched by first-release date: one request, through the server's shared one-a-second client, retried once if MusicBrainz says it's busy. A date that's only a year doesn't count, since it can't say whether something is recent. +- **Deezer's albums** for the artist: two requests. They carry a cover and the Deezer ID that a release's songs and previews come from. Deezer dates a reissue or deluxe edition to when it came out, so each release counts from its earliest edition on Deezer. Otherwise an old album would look new. + +The first source to list a release says what kind it is: MusicBrainz's types (`album`, `ep`, `single`, `compilation`, `live`) say more than Deezer's. Then the most precise date wins, and any source can fill in an ID or a cover. An artist's releases from the last 100 days are cached a day in `artist_releases` (migration 00030), or an hour if a source failed. Every 6 hours, the seeds of every set, then their ten nearest neighbors, are warmed one artist at a time, so the sources' limits hold. + +A set's Fresh feed (`GET /me/discover/sets/{setId}/fresh`) looks back its `fresh_days`: 30, 60 or 90, default 60. It takes the seeds and their nearest neighbors (15 of them, up to 50 for a set that explores) from the map's walk. Ranking: + +- Compilations and live albums come last. +- Ahead of them, the seeds' releases come first, then albums and EPs before singles. +- Within those, the score decides. For a neighbor, that's how near it is, with the same explore lean on how well known it is as the song feed, then the kind of release, then how recent it is. + +There are at most 3 releases per artist and 100 in all. Releases you have (`HasRelease`, by the artist found and the artist credited), want, or by an artist you blocked are left out. + +Like the map, it reads the cache. It fetches up to four seeds' releases that aren't cached, within 6 seconds, and warms the rest. Then it says it's `partial`, and the page loads it again a few times as it fills in. + +A release card shows its cover (Deezer's, else the Cover Art Archive's through `GET /me/discover/release-art/{mbid}`), its kind and date, and the seeds it's near. Opening it lists its songs from Deezer (`GET /me/discover/release-tracks`, by Deezer ID or found by title), each with a preview and a YouTube link. The card also has an "Open on YouTube" search for the whole release. In **Both** mode, the web app mixes Fresh cards into the deep-cuts feed with a **New** badge: one after the first song, then one every four. That keeps the song feed's pages as they are. + +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. + ### 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/cmd/syncphony/main.go b/server/cmd/syncphony/main.go index 0aa0a52..be97909 100644 --- a/server/cmd/syncphony/main.go +++ b/server/cmd/syncphony/main.go @@ -47,6 +47,7 @@ import ( "github.com/madeofpendletonwool/syncphony/server/internal/provider/spotify/streaming" "github.com/madeofpendletonwool/syncphony/server/internal/queue" "github.com/madeofpendletonwool/syncphony/server/internal/realtime" + "github.com/madeofpendletonwool/syncphony/server/internal/releases" "github.com/madeofpendletonwool/syncphony/server/internal/rooms" "github.com/madeofpendletonwool/syncphony/server/internal/store" "github.com/madeofpendletonwool/syncphony/server/internal/suggest" @@ -243,7 +244,7 @@ func run() error { } else { slog.Info("MusicBrainz is off: artwork comes only from linked services, and there are no liner notes") } - graph, deezer := newMusicGraph(cfg, db, mb, userAgent) + graph, deezer, lb := newMusicGraph(cfg, db, mb, userAgent) if len(graph.Sources()) > 0 { go graph.Run(ctx) go repairMusicGraph(ctx, graph) @@ -389,6 +390,27 @@ func run() error { } else { slog.Info("Deezer is off: discovery has no previews") } + // Recent releases, for Fresh feeds (MAD-822), from whichever of + // ListenBrainz, MusicBrainz and Deezer are on. + relOpts := releases.Options{} + if deezer != nil { + relOpts.Deezer = deezer + } + if lb != nil { + relOpts.ListenBrainz = lb + } + if mb != nil { + relOpts.MusicBrainz = mb + api.MusicBrainz = mb + } + rel := releases.New(db, relOpts) + if rel.On() { + api.Discover.Releases = rel + go rel.Run(ctx) + go api.Discover.Run(ctx) + } else { + slog.Info("no source of releases is on: discovery has no Fresh feed") + } go sweep(ctx, db, accounts, roomSvc, lyricsSvc, mb, notes, graph, beatMaps) go tick(ctx, api, nightSvc) mux := http.NewServeMux() @@ -450,20 +472,23 @@ func tick(ctx context.Context, api *httpapi.Server, ns *nights.Service) { } // newMusicGraph sets up the DJ's music knowledge (ADR 0012) from the -// sources that are configured. It returns Deezer too, if it's on, for -// discovery's previews (ADR 0017), which share its rate limit. -func newMusicGraph(cfg config.Config, db *store.Store, mb *musicbrainz.Service, userAgent string) (*musicgraph.Service, *musicgraph.Deezer) { +// sources that are configured. It returns Deezer and ListenBrainz too, if +// they're on, for discovery's previews and recent releases (ADR 0017), +// which share their rate limits. +func newMusicGraph(cfg config.Config, db *store.Store, mb *musicbrainz.Service, userAgent string) (*musicgraph.Service, *musicgraph.Deezer, *musicgraph.ListenBrainz) { var sources []musicgraph.Source var deezer *musicgraph.Deezer + var lb *musicgraph.ListenBrainz if cfg.LastFMKey != "" { sources = append(sources, musicgraph.NewLastFM(musicgraph.LastFMOptions{Key: cfg.LastFMKey, UserAgent: userAgent})) } else { slog.Info("Last.fm is off: set SYNCPHONY_LASTFM_KEY for the best similar artists and songs") } if cfg.ListenBrainzURL != "" { - sources = append(sources, musicgraph.NewListenBrainz(musicgraph.ListenBrainzOptions{ + lb = musicgraph.NewListenBrainz(musicgraph.ListenBrainzOptions{ BaseURL: cfg.ListenBrainzURL, Token: cfg.ListenBrainzToken, UserAgent: userAgent, - })) + }) + sources = append(sources, lb) } if cfg.DeezerURL != "" { deezer = musicgraph.NewDeezer(musicgraph.DeezerOptions{BaseURL: cfg.DeezerURL, UserAgent: userAgent}) @@ -481,7 +506,7 @@ func newMusicGraph(cfg config.Config, db *store.Store, mb *musicbrainz.Service, } else { slog.Info("music knowledge is off: no sources are configured") } - return graph, deezer + return graph, deezer, lb } // repairMusicGraph fetches again the music knowledge cached while a source diff --git a/server/internal/discover/discover.go b/server/internal/discover/discover.go index 97049bb..db7e369 100644 --- a/server/internal/discover/discover.go +++ b/server/internal/discover/discover.go @@ -15,6 +15,7 @@ import ( "context" "database/sql" "errors" + "slices" "strings" "sync" "time" @@ -45,8 +46,13 @@ const ( MaxName = 100 // DefaultExplore is a new set's explore, 0 to 100. DefaultExplore = 40 + // DefaultFreshDays is how far back a new set's Fresh feed looks. + DefaultFreshDays = 60 ) +// FreshWindows are how far back a Fresh feed can look, in days. +var FreshWindows = []int{30, 60, 90} + // Modes: which feeds a set shows. const ( ModeDeep = "deep" @@ -96,6 +102,9 @@ type Service struct { // Deezer finds songs' previews and artists by name. Nil means no // previews, and artist search only offers the name typed. Deezer Deezer + // Releases finds artists' recent releases, for Fresh feeds. Nil + // makes every Fresh feed empty. + Releases Releases // Now is the clock. Default store.Now. Now func() time.Time // BuildFor bounds building a feed; what's found by then is the feed. @@ -153,6 +162,13 @@ func checkMode(mode string) error { return &InvalidInputError{"mode is deep, fresh or both"} } +func checkFreshDays(days int) error { + if !slices.Contains(FreshWindows, days) { + return &InvalidInputError{"fresh days is 30, 60 or 90"} + } + return nil +} + func checkExplore(explore int) error { if explore < 0 || explore > 100 { return &InvalidInputError{"explore is 0 to 100"} @@ -217,19 +233,20 @@ func (s *Service) Get(ctx context.Context, userID, setID string) (Set, error) { // Changes are what Update changes; nil leaves a field as it is. type Changes struct { - Name *string - Explore *int - Mode *string + Name *string + Explore *int + Mode *string + FreshDays *int } -// Update renames a set, or changes its explore or mode. +// Update renames a set, or changes its explore, mode or Fresh window. func (s *Service) Update(ctx context.Context, userID, setID string, c Changes) (Set, error) { set, err := s.Get(ctx, userID, setID) if err != nil { return Set{}, err } p := store.UpdateDiscoverySetParams{ - ID: setID, UserID: userID, Name: set.Name, Explore: set.Explore, Mode: set.Mode, UpdatedAt: s.Now(), + ID: setID, UserID: userID, Name: set.Name, Explore: set.Explore, Mode: set.Mode, FreshDays: set.FreshDays, UpdatedAt: s.Now(), } if c.Name != nil { if p.Name, err = cleanName(*c.Name); err != nil { @@ -248,6 +265,12 @@ func (s *Service) Update(ctx context.Context, userID, setID string, c Changes) ( } p.Mode = *c.Mode } + if c.FreshDays != nil { + if err := checkFreshDays(*c.FreshDays); err != nil { + return Set{}, err + } + p.FreshDays = int64(*c.FreshDays) + } if err := s.db.UpdateDiscoverySet(ctx, p); err != nil { return Set{}, err } diff --git a/server/internal/discover/fresh.go b/server/internal/discover/fresh.go new file mode 100644 index 0000000..b24d113 --- /dev/null +++ b/server/internal/discover/fresh.go @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package discover + +import ( + "cmp" + "context" + "errors" + "log/slog" + "math" + "slices" + "strings" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/library" + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/releases" +) + +// The Fresh feed (MAD-823): recent releases by a set's seeds and their +// neighbors, "new releases for you". Releases come from internal/releases, +// cached a day per artist and warmed in the background; the feed reads the +// cache, fetches a few seeds' that aren't cached, and warms the rest. +const ( + // freshNeighbors and freshExploreNeighbors are how many of the + // nearest new artists' releases a Fresh feed looks at: more for a set + // that explores. + freshNeighbors = 15 + freshExploreNeighbors = 35 + // freshFetches is how many artists whose releases aren't cached a + // feed fetches, seeds first, within freshFetchFor; the rest are + // warmed, and the feed says it's partial. + freshFetches = 4 + freshFetchFor = 6 * time.Second + // freshPerArtist is the most releases of one artist in a feed, and + // maxFresh the most in all. + freshPerArtist = 3 + maxFresh = 100 + // warmNeighbors is how many of each seed's nearest artists have their + // releases warmed in the background, and warmEvery how often. + warmNeighbors = 10 + warmEvery = 6 * time.Hour + tracksForFeed = 10 * time.Second +) + +// Releases finds artists' recent releases. releases.Service is one. +type Releases interface { + On() bool + Cached(ctx context.Context, a musicgraph.ArtistRef) ([]releases.Release, bool, error) + Get(ctx context.Context, a musicgraph.ArtistRef) ([]releases.Release, error) + Warm(as ...musicgraph.ArtistRef) + Tracks(ctx context.Context, artist, title string, deezerID int64) (musicgraph.DeezerAlbum, []musicgraph.DeezerTrack, error) +} + +// FreshItem is a release in a Fresh feed. +type FreshItem struct { + releases.Release + // Because are the seed artists it's near, nearest first: the seed + // itself for a seed's release. + Because []string + // Seed is whether it's a seed artist's. + Seed bool + Score float64 +} + +// Fresh is a set's Fresh feed. +type Fresh struct { + Items []FreshItem + // Days is how far back it looks. + Days int + // Partial is whether some artists' releases weren't known yet: + // they're being looked up, and loading the feed again shows more. + Partial bool + Library library.Status + // NoGraph is whether the server knows nothing of how music relates, + // and NoReleases whether it has no source of releases: either way, + // the feed is empty or only the seeds'. + NoGraph, NoReleases bool +} + +// Fresh returns a set's recent releases: the seeds' first, then their +// neighbors', nearest first; albums and EPs before singles, and +// compilations and live albums last. Releases the user has, wants, or by +// artists they blocked are left out. +func (s *Service) Fresh(ctx context.Context, userID, setID string) (Fresh, error) { + set, err := s.Get(ctx, userID, setID) + if err != nil { + return Fresh{}, err + } + out := Fresh{Items: []FreshItem{}, Days: int(cmp.Or(set.FreshDays, DefaultFreshDays)), NoGraph: s.Graph == nil} + out.NoReleases = s.Releases == nil || !s.Releases.On() + keep, status, err := s.releaseFilter(ctx, userID) + if err != nil { + return Fresh{}, err + } + out.Library = status + if out.NoReleases || len(set.Artists) == 0 { + return out, nil + } + fetchBy := time.Now().Add(freshFetchFor) + + // Who: the seeds, and their nearest neighbors. + explore := float64(set.Explore) / 100 + m := &mapper{roots: map[string]*node{}, nodes: map[string]*node{}, blocked: map[string]bool{}} + if s.Graph != nil { + m.lk = &looker{g: s.Graph, ctx: ctx, budget: mapFetches, fetchBy: fetchBy} + } + var roots []musicgraph.ArtistRef + names := map[string]string{} + for _, a := range set.Artists { + roots = append(roots, musicgraph.ArtistRef{Name: a.Name, MBID: a.Mbid.String}) + names[library.ArtistKey(a.Name)] = a.Name + } + n := freshNeighbors + int(math.Round(freshExploreNeighbors*explore)) + near := m.walk(roots, 1, len(roots)+n) + near = near[:min(len(near), n)] + if m.lk != nil && m.lk.warm() { + out.Partial = true + } + top := 0.0 + for _, nd := range near { + top = max(top, nd.pull()) + } + + rl := &releaseLooker{r: s.Releases, ctx: ctx, budget: freshFetches, fetchBy: fetchBy} + cutoff := s.Now().AddDate(0, 0, -out.Days) + byKey := map[string]int{} + add := func(nd *node, seed bool) { + rs, ok := rl.get(nd.ref, seed) + if !ok { + return + } + pull, lean := 1.0, 1.0 + if !seed { + pull = nd.pull() / top + fame := 0.5 + if a, ok := m.get(nd.ref); ok { + fame = famous(a.Fans) + } + lean = 0.5 + 0.5*((1-explore)*fame+explore*(1-fame)) + } + because := nd.because(names) + taken := 0 + for _, r := range rs { + day, ok := r.Day() + if !ok || day.Before(cutoff) || taken == freshPerArtist || !keep(nd.ref.Name, r) { + continue + } + age := s.Now().Sub(day).Hours() / 24 + it := FreshItem{ + Release: r, Because: because, Seed: seed, + Score: pull * lean * kindWeight(r.Kind) * (0.8 + 0.2*max(0, 1-age/float64(out.Days))), + } + // A release by two artists in the set shows once, as theirs + // it suits best. + if i, dup := byKey[r.Key]; dup { + if better(it, out.Items[i]) { + out.Items[i] = it + } + continue + } + byKey[r.Key] = len(out.Items) + out.Items = append(out.Items, it) + taken++ + } + } + for _, nd := range m.order { + add(nd, true) + } + for _, nd := range near { + add(nd, false) + } + if rl.warm() { + out.Partial = true + } + slices.SortStableFunc(out.Items, func(a, b FreshItem) int { + if better(a, b) { + return -1 + } + if better(b, a) { + return 1 + } + return cmp.Compare(a.Key, b.Key) + }) + out.Items = out.Items[:min(len(out.Items), maxFresh)] + return out, nil +} + +// better says whether a ranks above b: compilations and live albums +// last, then the seeds' releases, then albums and EPs before singles, +// then the higher score, then the newer. +func better(a, b FreshItem) bool { + if x, y := minor(a.Kind), minor(b.Kind); x != y { + return y + } + if a.Seed != b.Seed { + return a.Seed + } + if x, y := a.Kind == releases.KindSingle, b.Kind == releases.KindSingle; x != y { + return y + } + if a.Score != b.Score { + return a.Score > b.Score + } + return a.Date > b.Date +} + +func minor(kind string) bool { return kind == releases.KindCompilation || kind == releases.KindLive } + +// kindWeight is how much a kind of release counts. +func kindWeight(kind string) float64 { + switch kind { + case releases.KindAlbum, releases.KindEP: + return 1 + case releases.KindSingle: + return 0.75 + } + return 0.4 +} + +// releaseFilter returns what says whether a release may be shown to a +// user, by an artist it was found under: not one they have or want, nor +// by an artist they blocked. +func (s *Service) releaseFilter(ctx context.Context, userID string) (func(artist string, r releases.Release) bool, library.Status, error) { + var owned *library.Owned + var status library.Status + if s.Library != nil { + o, err := s.Library.For(ctx, userID) + if err != nil { + return nil, status, err + } + owned, status = o, o.Status() + } + blocked := map[string]bool{} + bs, err := s.db.ListDiscoveryBlocks(ctx, userID) + if err != nil { + return nil, status, err + } + for _, b := range bs { + blocked[b.ArtistKey] = true + } + wanted := map[string]bool{} + ws, err := s.db.ListWantItems(ctx, userID) + if err != nil { + return nil, status, err + } + for _, w := range ws { + if w.Kind == "release" { + wanted[w.ItemKey] = true + } + } + return func(artist string, r releases.Release) bool { + return !blocked[library.ArtistKey(artist)] && !blocked[library.ArtistKey(r.Artist)] && + !wanted[r.Key] && !wanted[library.ReleaseKey(r.Artist, r.Title)] && + !owned.HasRelease(artist, r.Title) && !owned.HasRelease(r.Artist, r.Title) + }, status, nil +} + +// releaseLooker looks artists' releases up for one feed: from the cache, +// or fetched while there's budget and time left. A fetch still going then +// carries on in the background. Those it couldn't find are warmed. +type releaseLooker struct { + r Releases + ctx context.Context + budget int + fetchBy time.Time + missing []musicgraph.ArtistRef +} + +func (l *releaseLooker) get(a musicgraph.ArtistRef, fetch bool) ([]releases.Release, bool) { + rs, ok, err := l.r.Cached(l.ctx, a) + if err != nil { + slog.Warn("discover: reading an artist's releases", "artist", a.Name, "err", err) + return nil, false + } + if ok { + return rs, true + } + if fetch && l.budget > 0 && time.Now().Before(l.fetchBy) { + l.budget-- + type answer struct { + rs []releases.Release + err error + } + got := make(chan answer, 1) + go func() { + // Not cut short: what it finds is cached for next time. + rs, err := l.r.Get(context.WithoutCancel(l.ctx), a) + got <- answer{rs, err} + }() + wait := time.NewTimer(time.Until(l.fetchBy)) + defer wait.Stop() + select { + case r := <-got: + if r.err == nil { + return r.rs, true + } + if !errors.Is(r.err, provider.ErrNotFound) { + slog.Debug("discover: fetching an artist's releases", "artist", a.Name, "err", r.err) + } + return nil, false + case <-wait.C: + case <-l.ctx.Done(): + } + } + l.missing = append(l.missing, a) + return nil, false +} + +// warm asks for the artists it couldn't find, and reports whether there +// were any. +func (l *releaseLooker) warm() bool { + if len(l.missing) > 0 { + l.r.Warm(l.missing...) + } + return len(l.missing) > 0 +} + +// ReleaseTracks lists a release's songs, each with a preview when Deezer +// has one: by its Deezer ID, or found on Deezer by artist and title. +func (s *Service) ReleaseTracks(ctx context.Context, artist, title string, deezerID int64) ([]Item, error) { + artist = strings.Join(strings.Fields(artist), " ") + title = strings.Join(strings.Fields(title), " ") + if artist == "" || title == "" { + return nil, &InvalidInputError{"a release's artist and title are needed"} + } + if s.Releases == nil { + return []Item{}, nil + } + ctx, cancel := context.WithTimeout(ctx, tracksForFeed) + defer cancel() + album, tracks, err := s.Releases.Tracks(ctx, artist, title, deezerID) + if errors.Is(err, provider.ErrNotFound) { + return []Item{}, nil + } + if err != nil { + return nil, err + } + out := make([]Item, 0, len(tracks)) + for _, t := range tracks { + a := cmp.Or(t.Artist, artist) + out = append(out, Item{ + Key: SongKey(a, t.Title), Title: t.Title, Artist: a, Album: cmp.Or(album.Title, title), ISRC: t.ISRC, + DeezerID: t.ID, Cover: cmp.Or(t.Cover, album.Cover), Because: []string{}, + }) + } + return out, nil +} + +// WarmReleases asks for the releases of every set's seeds, and their +// nearest neighbors the graph knows, to be fetched in the background: +// those not cached already. +func (s *Service) WarmReleases(ctx context.Context) error { + if s.Releases == nil || !s.Releases.On() { + return nil + } + seeds, err := s.db.ListAllDiscoverySetArtists(ctx) + if err != nil { + return err + } + var want []musicgraph.ArtistRef + seen := map[string]bool{} + consider := func(a musicgraph.ArtistRef) { + k := library.ArtistKey(a.Name) + if k == "" || seen[k] { + return + } + seen[k] = true + if _, ok, err := s.Releases.Cached(ctx, a); err == nil && !ok { + want = append(want, a) + } + } + for _, sa := range seeds { + consider(musicgraph.ArtistRef{Name: sa.Name, MBID: sa.Mbid}) + } + // The neighbors after every seed, so seeds go first. + if s.Graph != nil { + for _, sa := range seeds { + a, ok, err := s.Graph.CachedArtist(ctx, musicgraph.ArtistRef{Name: sa.Name, MBID: sa.Mbid}) + if !ok || err != nil { + continue + } + for _, sim := range a.Similar[:min(len(a.Similar), warmNeighbors)] { + consider(sim.Artist) + } + } + } + s.Releases.Warm(want...) + return ctx.Err() +} + +// Run warms every set's releases now and then, until ctx is done. +func (s *Service) Run(ctx context.Context) { + t := time.NewTimer(time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + if err := s.WarmReleases(ctx); err != nil && ctx.Err() == nil { + slog.Warn("discover: warming releases", "err", err) + } + t.Reset(warmEvery) + } +} diff --git a/server/internal/discover/fresh_test.go b/server/internal/discover/fresh_test.go new file mode 100644 index 0000000..c4d84cb --- /dev/null +++ b/server/internal/discover/fresh_test.go @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package discover_test + +import ( + "context" + "errors" + "slices" + "sync" + "testing" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/discover" + "github.com/madeofpendletonwool/syncphony/server/internal/library" + "github.com/madeofpendletonwool/syncphony/server/internal/match" + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/releases" +) + +// releaseBook stands in for the release lookup: artists' releases, some +// cached, the rest only fetched. +type releaseBook struct { + mu sync.Mutex + releases map[string][]releases.Release + cached map[string]bool + fetched []string + warmed []string +} + +func (b *releaseBook) On() bool { return true } + +func (b *releaseBook) Cached(_ context.Context, a musicgraph.ArtistRef) ([]releases.Release, bool, error) { + b.mu.Lock() + defer b.mu.Unlock() + k := match.Simplify(a.Name) + return b.releases[k], b.cached[k], nil +} + +func (b *releaseBook) Get(_ context.Context, a musicgraph.ArtistRef) ([]releases.Release, error) { + b.mu.Lock() + defer b.mu.Unlock() + k := match.Simplify(a.Name) + b.fetched = append(b.fetched, a.Name) + b.cached[k] = true + return b.releases[k], nil +} + +func (b *releaseBook) Warm(as ...musicgraph.ArtistRef) { + b.mu.Lock() + defer b.mu.Unlock() + for _, a := range as { + b.warmed = append(b.warmed, a.Name) + } +} + +func (b *releaseBook) Tracks(_ context.Context, artist, title string, _ int64) (musicgraph.DeezerAlbum, []musicgraph.DeezerTrack, error) { + if title != "Pygmalion" { + return musicgraph.DeezerAlbum{}, nil, provider.ErrNotFound + } + return musicgraph.DeezerAlbum{ID: 77, Title: "Pygmalion", Cover: "pyg"}, []musicgraph.DeezerTrack{ + {ID: 771, Title: "Rutti", Artist: artist, ISRC: "GB0000000001"}, + {ID: 772, Title: "Crazy for You", Artist: artist}, + }, nil +} + +// release adds an artist's release, daysAgo days before e's now. +func (b *releaseBook) add(artist, title, kind string, daysAgo int, cached bool) { + b.mu.Lock() + defer b.mu.Unlock() + k := match.Simplify(artist) + b.releases[k] = append(b.releases[k], releases.Release{ + Key: library.ReleaseKey(artist, title), Title: title, Artist: artist, Kind: kind, + Date: freshNow.AddDate(0, 0, -daysAgo).Format(time.DateOnly), Sources: []string{"deezer"}, + }) + b.cached[k] = b.cached[k] || cached +} + +var freshNow = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + +func freshEnv(t *testing.T) (*env, *releaseBook) { + t.Helper() + e := newEnv(t) + e.svc.Now = func() time.Time { return freshNow } + b := &releaseBook{releases: map[string][]releases.Release{}, cached: map[string]bool{}} + e.svc.Releases = b + return e, b +} + +func freshTitles(f discover.Fresh) []string { + var out []string + for _, it := range f.Items { + out = append(out, it.Artist+": "+it.Title) + } + return out +} + +func TestFreshFeed(t *testing.T) { + e, b := freshEnv(t) + b.add("Slowdive", "Everything Is Alive", "album", 20, true) + b.add("Slowdive", "Kisses", "single", 40, true) + b.add("Slowdive", "Live at the Roundhouse", "live", 5, true) + b.add("Slowdive", "Souvlaki", "album", 400, true) // too old + b.add("Hum", "Inlet", "album", 50, true) + b.add("Swirlies", "Cats of the Wild", "ep", 10, true) + b.add("Ride", "Interplay", "album", 30, true) + b.add("Ride", "Peace Sign", "single", 3, true) + b.add("Lush", "Lovelife", "album", 15, true) // owned + b.add("Failure", "Wild Type", "single", 8, true) // blocked + b.add("Codeine", "Barely Real", "ep", 59, true) + b.add("Codeine", "White Birch", "album", 61, true) // past the window + // Everyone else is known to have put nothing out. + for k := range e.graph.artists { + b.cached[k] = true + } + credit := []provider.ArtistCredit{{Name: "Lush"}} + e.svc.Library = owned{library.Holding(provider.Track{Title: "Ladykillers", Artists: credit, Album: provider.AlbumCredit{Title: "Lovelife"}})} + if _, err := e.svc.Block(t.Context(), e.alice, "Failure"); err != nil { + t.Fatal(err) + } + set := e.set(t, "Slowdive", "Hum", "Duster") + f, err := e.svc.Fresh(t.Context(), e.alice, set.ID) + if err != nil { + t.Fatal(err) + } + // The seeds' first, albums before singles; then the neighbors', the + // one near all three seeds first; live albums last. + want := []string{ + "Slowdive: Everything Is Alive", + "Hum: Inlet", + "Slowdive: Kisses", + "Swirlies: Cats of the Wild", + "Ride: Interplay", + "Codeine: Barely Real", + "Ride: Peace Sign", + "Slowdive: Live at the Roundhouse", + } + if got := freshTitles(f); !slices.Equal(got, want) { + t.Errorf("fresh:\n got %q\nwant %q", got, want) + } + if f.Days != 60 || f.Partial || f.NoReleases { + t.Errorf("days %d, partial %v, no releases %v", f.Days, f.Partial, f.NoReleases) + } + for _, it := range f.Items { + switch it.Artist { + case "Slowdive": + if !it.Seed || !slices.Equal(it.Because, []string{"Slowdive"}) { + t.Errorf("a seed's release: %+v", it) + } + case "Swirlies": + if it.Seed || len(it.Because) != 2 { + t.Errorf("a neighbor's release: %+v", it) + } + } + } + + // A wider window takes in more. + days := 90 + if _, err := e.svc.Update(t.Context(), e.alice, set.ID, discover.Changes{FreshDays: &days}); err != nil { + t.Fatal(err) + } + f, err = e.svc.Fresh(t.Context(), e.alice, set.ID) + if err != nil { + t.Fatal(err) + } + if f.Days != 90 || !slices.Contains(freshTitles(f), "Codeine: White Birch") { + t.Errorf("90 days: %q", freshTitles(f)) + } + bad := 45 + var invalid *discover.InvalidInputError + if _, err := e.svc.Update(t.Context(), e.alice, set.ID, discover.Changes{FreshDays: &bad}); !errors.As(err, &invalid) { + t.Errorf("45 days: %v", err) + } +} + +func TestFreshFeedFetchesSeedsAndWarmsTheRest(t *testing.T) { + e, b := freshEnv(t) + b.add("Slowdive", "Everything Is Alive", "album", 20, false) + b.add("Ride", "Interplay", "album", 30, false) + set := e.set(t, "Slowdive") + f, err := e.svc.Fresh(t.Context(), e.alice, set.ID) + if err != nil { + t.Fatal(err) + } + if got := freshTitles(f); !slices.Equal(got, []string{"Slowdive: Everything Is Alive"}) { + t.Errorf("fresh: %q", got) + } + if !f.Partial || !slices.Contains(b.warmed, "Ride") || slices.Contains(b.fetched, "Ride") { + t.Errorf("partial %v; fetched %v, warmed %v", f.Partial, b.fetched, b.warmed) + } +} + +func TestFreshFeedIsYours(t *testing.T) { + e, _ := freshEnv(t) + set := e.set(t, "Slowdive") + if _, err := e.svc.Fresh(t.Context(), e.bob, set.ID); !errors.Is(err, discover.ErrNotFound) { + t.Errorf("someone else's set: %v", err) + } + e.svc.Releases = nil + f, err := e.svc.Fresh(t.Context(), e.alice, set.ID) + if err != nil || !f.NoReleases || len(f.Items) != 0 { + t.Errorf("no source of releases: %+v, %v", f, err) + } +} + +func TestReleaseTracks(t *testing.T) { + e, _ := freshEnv(t) + items, err := e.svc.ReleaseTracks(t.Context(), "Slowdive", "Pygmalion", 0) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 || items[0].DeezerID != 771 || items[0].Album != "Pygmalion" || items[0].Cover != "pyg" || items[0].ISRC != "GB0000000001" { + t.Errorf("tracks: %+v", items) + } + if items, err := e.svc.ReleaseTracks(t.Context(), "Slowdive", "Not on Deezer", 0); err != nil || len(items) != 0 { + t.Errorf("not on Deezer: %+v, %v", items, err) + } + var invalid *discover.InvalidInputError + if _, err := e.svc.ReleaseTracks(t.Context(), " ", "Pygmalion", 0); !errors.As(err, &invalid) { + t.Errorf("no artist: %v", err) + } +} + +func TestWarmReleases(t *testing.T) { + e, b := freshEnv(t) + b.add("Hum", "Inlet", "album", 50, true) + e.set(t, "Slowdive", "Hum") + if _, err := e.svc.Create(t.Context(), e.bob, "Bob's", []discover.Seed{{Name: "slowdive"}, {Name: "Duster"}}); err != nil { + t.Fatal(err) + } + if err := e.svc.WarmReleases(t.Context()); err != nil { + t.Fatal(err) + } + // Every set's seeds once, those not cached, then their neighbors. + if len(b.warmed) < 3 || !slices.Equal(b.warmed[:2], []string{"Duster", "Slowdive"}) { + t.Fatalf("warmed %v", b.warmed) + } + for _, name := range []string{"Ride", "Swirlies", "Failure", "Codeine"} { + if !slices.Contains(b.warmed, name) { + t.Errorf("didn't warm %s: %v", name, b.warmed) + } + } + if slices.Contains(b.warmed, "Hum") { + t.Error("warmed a cached artist") + } +} diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go index b75712c..e869c0a 100644 --- a/server/internal/httpapi/api.gen.go +++ b/server/internal/httpapi/api.gen.go @@ -198,6 +198,27 @@ func (e DiscoveryFeedbackRequestKind) Valid() bool { } } +// Defines values for DiscoveryFreshDays. +const ( + DiscoveryFresh30Days DiscoveryFreshDays = 30 + DiscoveryFresh60Days DiscoveryFreshDays = 60 + DiscoveryFresh90Days DiscoveryFreshDays = 90 +) + +// Valid indicates whether the value is a known member of the DiscoveryFreshDays enum. +func (e DiscoveryFreshDays) Valid() bool { + switch e { + case DiscoveryFresh30Days: + return true + case DiscoveryFresh60Days: + return true + case DiscoveryFresh90Days: + return true + default: + return false + } +} + // Defines values for DiscoveryMode. const ( DiscoveryModeBoth DiscoveryMode = "both" @@ -219,6 +240,33 @@ func (e DiscoveryMode) Valid() bool { } } +// Defines values for DiscoveryReleaseKind. +const ( + DiscoveryReleaseKindAlbum DiscoveryReleaseKind = "album" + DiscoveryReleaseKindCompilation DiscoveryReleaseKind = "compilation" + DiscoveryReleaseKindEp DiscoveryReleaseKind = "ep" + DiscoveryReleaseKindLive DiscoveryReleaseKind = "live" + DiscoveryReleaseKindSingle DiscoveryReleaseKind = "single" +) + +// Valid indicates whether the value is a known member of the DiscoveryReleaseKind enum. +func (e DiscoveryReleaseKind) Valid() bool { + switch e { + case DiscoveryReleaseKindAlbum: + return true + case DiscoveryReleaseKindCompilation: + return true + case DiscoveryReleaseKindEp: + return true + case DiscoveryReleaseKindLive: + return true + case DiscoveryReleaseKindSingle: + return true + default: + return false + } +} + // Defines values for DiscoverySetArtistAddedFrom. const ( DiscoverySetArtistAddedFromManual DiscoverySetArtistAddedFrom = "manual" @@ -2134,6 +2182,28 @@ type DiscoveryFeedbackRequest struct { // DiscoveryFeedbackRequestKind defines model for DiscoveryFeedbackRequest.Kind. type DiscoveryFeedbackRequestKind string +// DiscoveryFresh defines model for DiscoveryFresh. +type DiscoveryFresh struct { + // Days How far back a set's Fresh feed looks, in days. + Days DiscoveryFreshDays `json:"days"` + Items []DiscoveryRelease `json:"items"` + + // Library How far your library (your own Navidrome links) is indexed. + Library DiscoveryLibrary `json:"library"` + + // NoGraph The server knows nothing of how music relates, so only the seeds' releases show. + NoGraph bool `json:"noGraph"` + + // NoReleases The server has no source of releases (Deezer, ListenBrainz and MusicBrainz are all off). + NoReleases bool `json:"noReleases"` + + // Partial Some artists' releases weren't known yet. They're being looked up; load it again in a while. + Partial bool `json:"partial"` +} + +// DiscoveryFreshDays How far back a set's Fresh feed looks, in days. +type DiscoveryFreshDays int + // DiscoveryItem A song you don't have, near a set's seeds. type DiscoveryItem struct { Album *string `json:"album,omitempty"` @@ -2232,9 +2302,48 @@ type DiscoveryMapNode struct { } // DiscoveryMode Which feeds a set shows: `deep` cuts (anything new to you), `fresh` -// releases (coming), or `both`. +// releases (recent albums, EPs and singles), or `both`. type DiscoveryMode string +// DiscoveryRelease A recent release near a set's seeds, that you don't have. +type DiscoveryRelease struct { + // Artist Who it's credited to. + Artist string `json:"artist"` + + // Because The seed artists it's near, nearest first; the seed itself for a seed's release. + Because []string `json:"because"` + + // Cover Its cover's image ID on Deezer, for getDiscoveryImage (kind `cover`). + Cover *string `json:"cover,omitempty"` + + // CoverRelease A release of it with a cover in the Cover Art Archive, for getDiscoveryReleaseArt. + CoverRelease *string `json:"coverRelease,omitempty"` + + // Date When it first came out, YYYY-MM-DD (or YYYY-MM, if that's all that's known). + Date string `json:"date"` + + // DeezerId The release on Deezer, for its songs. + DeezerId *int64 `json:"deezerId,omitempty"` + Key string `json:"key"` + Kind DiscoveryReleaseKind `json:"kind"` + + // Mbid Its MusicBrainz release group. + Mbid *string `json:"mbid,omitempty"` + + // Seed Whether it's a seed artist's. + Seed bool `json:"seed"` + Sources []string `json:"sources"` + Title string `json:"title"` +} + +// DiscoveryReleaseKind defines model for DiscoveryRelease.Kind. +type DiscoveryReleaseKind string + +// DiscoveryReleaseTracks defines model for DiscoveryReleaseTracks. +type DiscoveryReleaseTracks struct { + Items []DiscoveryItem `json:"items"` +} + // DiscoverySeed defines model for DiscoverySeed. type DiscoverySeed struct { Mbid *string `json:"mbid,omitempty"` @@ -2247,11 +2356,14 @@ type DiscoverySet struct { CreatedAt time.Time `json:"createdAt"` // Explore How far the feed strays from the seeds, and toward less-known artists. - Explore int `json:"explore"` - Id string `json:"id"` + Explore int `json:"explore"` + + // FreshDays How far back a set's Fresh feed looks, in days. + FreshDays DiscoveryFreshDays `json:"freshDays"` + Id string `json:"id"` // Mode Which feeds a set shows: `deep` cuts (anything new to you), `fresh` - // releases (coming), or `both`. + // releases (recent albums, EPs and singles), or `both`. Mode DiscoveryMode `json:"mode"` Name string `json:"name"` UpdatedAt time.Time `json:"updatedAt"` @@ -2276,10 +2388,13 @@ type DiscoverySetSummary struct { ArtistCount int `json:"artistCount"` CreatedAt time.Time `json:"createdAt"` Explore int `json:"explore"` - Id string `json:"id"` + + // FreshDays How far back a set's Fresh feed looks, in days. + FreshDays DiscoveryFreshDays `json:"freshDays"` + Id string `json:"id"` // Mode Which feeds a set shows: `deep` cuts (anything new to you), `fresh` - // releases (coming), or `both`. + // releases (recent albums, EPs and singles), or `both`. Mode DiscoveryMode `json:"mode"` Name string `json:"name"` UpdatedAt time.Time `json:"updatedAt"` @@ -4562,8 +4677,11 @@ type TransferRoomRequest struct { type UpdateDiscoverySetRequest struct { Explore *int `json:"explore,omitempty"` + // FreshDays How far back a set's Fresh feed looks, in days. + FreshDays *DiscoveryFreshDays `json:"freshDays,omitempty"` + // Mode Which feeds a set shows: `deep` cuts (anything new to you), `fresh` - // releases (coming), or `both`. + // releases (recent albums, EPs and singles), or `both`. Mode *DiscoveryMode `json:"mode,omitempty"` Name *string `json:"name,omitempty"` } @@ -4826,6 +4944,22 @@ type GetDiscoveryPreviewParams struct { Range *string `json:"Range,omitempty"` } +// GetDiscoveryReleaseArtParams defines parameters for GetDiscoveryReleaseArt. +type GetDiscoveryReleaseArtParams struct { + // Group The MBID is a release group's. + Group *bool `form:"group,omitempty" json:"group,omitempty"` + + // Size Wanted width in pixels; a hint. + Size *int `form:"size,omitempty" json:"size,omitempty"` +} + +// ListDiscoveryReleaseTracksParams defines parameters for ListDiscoveryReleaseTracks. +type ListDiscoveryReleaseTracksParams struct { + Artist string `form:"artist" json:"artist"` + Title string `form:"title" json:"title"` + DeezerId *int64 `form:"deezerId,omitempty" json:"deezerId,omitempty"` +} + // GetDiscoveryFeedParams defines parameters for GetDiscoveryFeed. type GetDiscoveryFeedParams struct { // Cursor The previous page's `next`. @@ -5322,6 +5456,12 @@ type ServerInterface interface { // GetDiscoveryPreview A song's 30-second preview // (GET /me/discover/preview/{deezerTrackId}) GetDiscoveryPreview(w http.ResponseWriter, r *http.Request, deezerTrackId int64, params GetDiscoveryPreviewParams) + // GetDiscoveryReleaseArt A release's cover from the Cover Art Archive, through the server + // (GET /me/discover/release-art/{mbid}) + GetDiscoveryReleaseArt(w http.ResponseWriter, r *http.Request, mbid string, params GetDiscoveryReleaseArtParams) + // ListDiscoveryReleaseTracks A release's songs, with previews + // (GET /me/discover/release-tracks) + ListDiscoveryReleaseTracks(w http.ResponseWriter, r *http.Request, params ListDiscoveryReleaseTracksParams) // ListDiscoverySets Your discovery sets // (GET /me/discover/sets) ListDiscoverySets(w http.ResponseWriter, r *http.Request) @@ -5346,6 +5486,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) + // GetDiscoveryFresh A set's Fresh feed + // (GET /me/discover/sets/{setId}/fresh) + GetDiscoveryFresh(w http.ResponseWriter, r *http.Request, setId DiscoverySetId) // ListDiscoveryArtistSongs An artist's songs you don't have // (GET /me/discover/songs) ListDiscoveryArtistSongs(w http.ResponseWriter, r *http.Request, params ListDiscoveryArtistSongsParams) @@ -7327,6 +7470,120 @@ func (siw *ServerInterfaceWrapper) GetDiscoveryPreview(w http.ResponseWriter, r handler.ServeHTTP(w, r) } +// GetDiscoveryReleaseArt operation middleware +func (siw *ServerInterfaceWrapper) GetDiscoveryReleaseArt(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "mbid" ------------- + var mbid string + + err = runtime.BindStyledParameterWithOptions("simple", "mbid", r.PathValue("mbid"), &mbid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "mbid", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetDiscoveryReleaseArtParams + + // ------------- Optional query parameter "group" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "group", r.URL.Query(), ¶ms.Group, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "group"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "group", Err: err}) + } + return + } + + // ------------- Optional query parameter "size" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "size", r.URL.Query(), ¶ms.Size, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "size"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "size", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetDiscoveryReleaseArt(w, r, mbid, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListDiscoveryReleaseTracks operation middleware +func (siw *ServerInterfaceWrapper) ListDiscoveryReleaseTracks(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ListDiscoveryReleaseTracksParams + + // ------------- 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 + } + + // ------------- Required query parameter "title" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "title", r.URL.Query(), ¶ms.Title, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "title"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "title", Err: err}) + } + return + } + + // ------------- Optional query parameter "deezerId" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "deezerId", r.URL.Query(), ¶ms.DeezerId, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "deezerId"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "deezerId", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListDiscoveryReleaseTracks(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListDiscoverySets operation middleware func (siw *ServerInterfaceWrapper) ListDiscoverySets(w http.ResponseWriter, r *http.Request) { @@ -7549,6 +7806,32 @@ func (siw *ServerInterfaceWrapper) GetDiscoveryFeed(w http.ResponseWriter, r *ht handler.ServeHTTP(w, r) } +// GetDiscoveryFresh operation middleware +func (siw *ServerInterfaceWrapper) GetDiscoveryFresh(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "setId" ------------- + var setId DiscoverySetId + + err = runtime.BindStyledParameterWithOptions("simple", "setId", r.PathValue("setId"), &setId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "setId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetDiscoveryFresh(w, r, setId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListDiscoveryArtistSongs operation middleware func (siw *ServerInterfaceWrapper) ListDiscoveryArtistSongs(w http.ResponseWriter, r *http.Request) { @@ -11069,6 +11352,9 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/discover/sets/{setId}/artists", wrapper.AddDiscoverySetArtist) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/me/discover/sets/{setId}/artists/{artistKey}", wrapper.RemoveDiscoverySetArtist) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/sets/{setId}/feed", wrapper.GetDiscoveryFeed) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/sets/{setId}/fresh", wrapper.GetDiscoveryFresh) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/release-tracks", wrapper.ListDiscoveryReleaseTracks) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/release-art/{mbid}", wrapper.GetDiscoveryReleaseArt) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/discover/feedback", wrapper.PostDiscoveryFeedback) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/blocks", wrapper.ListDiscoveryBlocks) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/discover/blocks", wrapper.BlockDiscoveryArtist) @@ -13704,6 +13990,92 @@ func (response GetDiscoveryPreviewdefaultJSONResponse) VisitGetDiscoveryPreviewR return err } +type GetDiscoveryReleaseArtRequestObject struct { + Mbid string `json:"mbid"` + Params GetDiscoveryReleaseArtParams +} + +type GetDiscoveryReleaseArtResponseObject interface { + VisitGetDiscoveryReleaseArtResponse(w http.ResponseWriter) error +} + +type GetDiscoveryReleaseArt200ImageResponse struct { + Body io.Reader + ContentType string + ContentLength int64 +} + +func (response GetDiscoveryReleaseArt200ImageResponse) VisitGetDiscoveryReleaseArtResponse(w http.ResponseWriter) error { + + w.Header().Set("Content-Type", response.ContentType) + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } + w.WriteHeader(200) + + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + _, err := io.Copy(w, response.Body) + return err +} + +type GetDiscoveryReleaseArtdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetDiscoveryReleaseArtdefaultJSONResponse) VisitGetDiscoveryReleaseArtResponse(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 ListDiscoveryReleaseTracksRequestObject struct { + Params ListDiscoveryReleaseTracksParams +} + +type ListDiscoveryReleaseTracksResponseObject interface { + VisitListDiscoveryReleaseTracksResponse(w http.ResponseWriter) error +} + +type ListDiscoveryReleaseTracks200JSONResponse DiscoveryReleaseTracks + +func (response ListDiscoveryReleaseTracks200JSONResponse) VisitListDiscoveryReleaseTracksResponse(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 ListDiscoveryReleaseTracksdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ListDiscoveryReleaseTracksdefaultJSONResponse) VisitListDiscoveryReleaseTracksResponse(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 ListDiscoverySetsRequestObject struct { } @@ -14013,6 +14385,45 @@ func (response GetDiscoveryFeeddefaultJSONResponse) VisitGetDiscoveryFeedRespons return err } +type GetDiscoveryFreshRequestObject struct { + SetId DiscoverySetId `json:"setId"` +} + +type GetDiscoveryFreshResponseObject interface { + VisitGetDiscoveryFreshResponse(w http.ResponseWriter) error +} + +type GetDiscoveryFresh200JSONResponse DiscoveryFresh + +func (response GetDiscoveryFresh200JSONResponse) VisitGetDiscoveryFreshResponse(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 GetDiscoveryFreshdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetDiscoveryFreshdefaultJSONResponse) VisitGetDiscoveryFreshResponse(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 ListDiscoveryArtistSongsRequestObject struct { Params ListDiscoveryArtistSongsParams } @@ -18475,6 +18886,12 @@ type StrictServerInterface interface { // GetDiscoveryPreview A song's 30-second preview // (GET /me/discover/preview/{deezerTrackId}) GetDiscoveryPreview(ctx context.Context, request GetDiscoveryPreviewRequestObject) (GetDiscoveryPreviewResponseObject, error) + // GetDiscoveryReleaseArt A release's cover from the Cover Art Archive, through the server + // (GET /me/discover/release-art/{mbid}) + GetDiscoveryReleaseArt(ctx context.Context, request GetDiscoveryReleaseArtRequestObject) (GetDiscoveryReleaseArtResponseObject, error) + // ListDiscoveryReleaseTracks A release's songs, with previews + // (GET /me/discover/release-tracks) + ListDiscoveryReleaseTracks(ctx context.Context, request ListDiscoveryReleaseTracksRequestObject) (ListDiscoveryReleaseTracksResponseObject, error) // ListDiscoverySets Your discovery sets // (GET /me/discover/sets) ListDiscoverySets(ctx context.Context, request ListDiscoverySetsRequestObject) (ListDiscoverySetsResponseObject, error) @@ -18499,6 +18916,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) + // GetDiscoveryFresh A set's Fresh feed + // (GET /me/discover/sets/{setId}/fresh) + GetDiscoveryFresh(ctx context.Context, request GetDiscoveryFreshRequestObject) (GetDiscoveryFreshResponseObject, error) // ListDiscoveryArtistSongs An artist's songs you don't have // (GET /me/discover/songs) ListDiscoveryArtistSongs(ctx context.Context, request ListDiscoveryArtistSongsRequestObject) (ListDiscoveryArtistSongsResponseObject, error) @@ -20663,6 +21083,59 @@ func (sh *strictHandler) GetDiscoveryPreview(w http.ResponseWriter, r *http.Requ } } +// GetDiscoveryReleaseArt operation middleware +func (sh *strictHandler) GetDiscoveryReleaseArt(w http.ResponseWriter, r *http.Request, mbid string, params GetDiscoveryReleaseArtParams) { + var request GetDiscoveryReleaseArtRequestObject + + request.Mbid = mbid + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetDiscoveryReleaseArt(ctx, request.(GetDiscoveryReleaseArtRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetDiscoveryReleaseArt") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetDiscoveryReleaseArtResponseObject); ok { + if err := validResponse.VisitGetDiscoveryReleaseArtResponse(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)) + } +} + +// ListDiscoveryReleaseTracks operation middleware +func (sh *strictHandler) ListDiscoveryReleaseTracks(w http.ResponseWriter, r *http.Request, params ListDiscoveryReleaseTracksParams) { + var request ListDiscoveryReleaseTracksRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListDiscoveryReleaseTracks(ctx, request.(ListDiscoveryReleaseTracksRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListDiscoveryReleaseTracks") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListDiscoveryReleaseTracksResponseObject); ok { + if err := validResponse.VisitListDiscoveryReleaseTracksResponse(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)) + } +} + // ListDiscoverySets operation middleware func (sh *strictHandler) ListDiscoverySets(w http.ResponseWriter, r *http.Request) { var request ListDiscoverySetsRequestObject @@ -20890,6 +21363,32 @@ func (sh *strictHandler) GetDiscoveryFeed(w http.ResponseWriter, r *http.Request } } +// GetDiscoveryFresh operation middleware +func (sh *strictHandler) GetDiscoveryFresh(w http.ResponseWriter, r *http.Request, setId DiscoverySetId) { + var request GetDiscoveryFreshRequestObject + + request.SetId = setId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetDiscoveryFresh(ctx, request.(GetDiscoveryFreshRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetDiscoveryFresh") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetDiscoveryFreshResponseObject); ok { + if err := validResponse.VisitGetDiscoveryFreshResponse(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)) + } +} + // ListDiscoveryArtistSongs operation middleware func (sh *strictHandler) ListDiscoveryArtistSongs(w http.ResponseWriter, r *http.Request, params ListDiscoveryArtistSongsParams) { var request ListDiscoveryArtistSongsRequestObject diff --git a/server/internal/httpapi/discover.go b/server/internal/httpapi/discover.go index 334f3cc..0c8529f 100644 --- a/server/internal/httpapi/discover.go +++ b/server/internal/httpapi/discover.go @@ -19,7 +19,7 @@ import ( func toDiscoverySet(set discover.Set) DiscoverySet { out := DiscoverySet{ - Id: set.ID, Name: set.Name, Explore: int(set.Explore), Mode: DiscoveryMode(set.Mode), + Id: set.ID, Name: set.Name, Explore: int(set.Explore), Mode: DiscoveryMode(set.Mode), FreshDays: DiscoveryFreshDays(set.FreshDays), Artists: make([]DiscoverySetArtist, len(set.Artists)), CreatedAt: set.CreatedAt, UpdatedAt: set.UpdatedAt, } for i, a := range set.Artists { @@ -59,7 +59,7 @@ func (s *Server) ListDiscoverySets(ctx context.Context, _ ListDiscoverySetsReque out := make(ListDiscoverySets200JSONResponse, len(sets)) for i, set := range sets { out[i] = DiscoverySetSummary{ - Id: set.ID, Name: set.Name, Explore: int(set.Explore), Mode: DiscoveryMode(set.Mode), ArtistCount: int(set.Artists), + Id: set.ID, Name: set.Name, Explore: int(set.Explore), Mode: DiscoveryMode(set.Mode), FreshDays: DiscoveryFreshDays(set.FreshDays), ArtistCount: int(set.Artists), CreatedAt: set.CreatedAt, UpdatedAt: set.UpdatedAt, } } @@ -106,6 +106,9 @@ func (s *Server) UpdateDiscoverySet(ctx context.Context, req UpdateDiscoverySetR if req.Body.Mode != nil { c.Mode = ptr(string(*req.Body.Mode)) } + if req.Body.FreshDays != nil { + c.FreshDays = ptr(int(*req.Body.FreshDays)) + } set, err := s.Discover.Update(ctx, u.ID, req.SetId, c) if err != nil { return nil, err @@ -179,6 +182,79 @@ func toDiscoveryItems(items []discover.Item) []DiscoveryItem { return out } +// GetDiscoveryFresh returns a set's recent releases. +func (s *Server) GetDiscoveryFresh(ctx context.Context, req GetDiscoveryFreshRequestObject) (GetDiscoveryFreshResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + f, err := s.Discover.Fresh(ctx, u.ID, req.SetId) + if err != nil { + return nil, err + } + out := DiscoveryFresh{ + Items: make([]DiscoveryRelease, len(f.Items)), Days: DiscoveryFreshDays(f.Days), Partial: f.Partial, + Library: toDiscoveryLibrary(f.Library), NoGraph: f.NoGraph, NoReleases: f.NoReleases, + } + for i, it := range f.Items { + out.Items[i] = DiscoveryRelease{ + Key: it.Key, Title: it.Title, Artist: it.Artist, Date: it.Date, Kind: DiscoveryReleaseKind(it.Kind), + Mbid: nonEmpty(it.MBID), CoverRelease: nonEmpty(it.CoverRelease), DeezerId: nonZero(it.DeezerID), Cover: nonEmpty(it.Cover), + Sources: it.Sources, Because: it.Because, Seed: it.Seed, + } + if out.Items[i].Because == nil { + out.Items[i].Because = []string{} + } + if out.Items[i].Sources == nil { + out.Items[i].Sources = []string{} + } + } + return GetDiscoveryFresh200JSONResponse(out), nil +} + +// ListDiscoveryReleaseTracks lists a release's songs, with previews. +func (s *Server) ListDiscoveryReleaseTracks(ctx context.Context, req ListDiscoveryReleaseTracksRequestObject) (ListDiscoveryReleaseTracksResponseObject, error) { + if _, err := member(ctx); err != nil { + return nil, err + } + var id int64 + if req.Params.DeezerId != nil { + id = *req.Params.DeezerId + } + items, err := s.Discover.ReleaseTracks(ctx, req.Params.Artist, req.Params.Title, id) + if err != nil { + return nil, err + } + return ListDiscoveryReleaseTracks200JSONResponse(DiscoveryReleaseTracks{Items: toDiscoveryItems(items)}), nil +} + +// GetDiscoveryReleaseArt loads a release's cover from the Cover Art Archive. +func (s *Server) GetDiscoveryReleaseArt(ctx context.Context, req GetDiscoveryReleaseArtRequestObject) (GetDiscoveryReleaseArtResponseObject, error) { + if _, err := member(ctx); err != nil { + return nil, err + } + if s.MusicBrainz == nil { + return nil, provider.ErrNotFound + } + size := 250 + if req.Params.Size != nil { + size = *req.Params.Size + } + release, group := req.Mbid, "" + if req.Params.Group != nil && *req.Params.Group { + release, group = "", req.Mbid + } + img, err := s.MusicBrainz.ReleaseArt(ctx, release, group, size) + if err != nil { + return nil, err + } + return artworkResponse{body: io.NopCloser(bytes.NewReader(img.Data)), ct: img.ContentType}, nil +} + +func (r artworkResponse) VisitGetDiscoveryReleaseArtResponse(w http.ResponseWriter) error { + return r.write(w) +} + // 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) diff --git a/server/internal/httpapi/discover_test.go b/server/internal/httpapi/discover_test.go index 21e62e8..e18b3a1 100644 --- a/server/internal/httpapi/discover_test.go +++ b/server/internal/httpapi/discover_test.go @@ -15,6 +15,7 @@ import ( "github.com/madeofpendletonwool/syncphony/server/internal/httpapi" "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/releases" ) // discoverGraph knows Hum, who's like Failure and Shiner. @@ -186,3 +187,84 @@ func TestGuestsHaveNoDiscovery(t *testing.T) { } } } + +func TestDiscoverFresh(t *testing.T) { + e := newEnv(t) + alice := e.admin() + bob := e.member(alice, "bob") + e.api.Discover.Graph = discoverGraph{} + // Deezer knows Hum and Failure, and the songs on Hum's new album. + recent := func(days int) string { return time.Now().AddDate(0, 0, -days).Format(time.DateOnly) } + dz := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path + "?" + r.URL.Query().Get("q") { + case "/search/artist?Hum": + fmt.Fprint(w, `{"data":[{"id":1,"name":"Hum","nb_fan":300000}]}`) + case "/search/artist?Failure": + fmt.Fprint(w, `{"data":[{"id":2,"name":"Failure","nb_fan":200000}]}`) + case "/artist/1/albums?": + fmt.Fprintf(w, `{"data":[{"id":10,"title":"Inlet","release_date":%q,"record_type":"album","md5_image":"0123456789abcdef0123456789abcdef"}, + {"id":11,"title":"Downward Is Heavenward","release_date":"1998-01-27","record_type":"album"}]}`, recent(20)) + case "/artist/2/albums?": + fmt.Fprintf(w, `{"data":[{"id":20,"title":"Wild Type","release_date":%q,"record_type":"single"}]}`, recent(5)) + case "/album/10/tracks?": + fmt.Fprint(w, `{"data":[{"id":101,"title":"Waves","preview":"x","artist":{"name":"Hum"}},{"id":102,"title":"In the Den","artist":{"name":"Hum"}}]}`) + default: + fmt.Fprint(w, `{"error":{"code":800,"message":"no data"}}`) + } + })) + t.Cleanup(dz.Close) + rel := releases.New(e.db, releases.Options{Deezer: musicgraph.NewDeezer(musicgraph.DeezerOptions{BaseURL: dz.URL, Interval: -1})}) + e.api.Discover.Releases = rel + + var set httpapi.DiscoverySet + alice.want(http.StatusCreated, "POST", "/me/discover/sets", httpapi.CreateDiscoverySetRequest{ + Name: "90s alt", Artists: &[]httpapi.DiscoverySeed{{Name: "Hum"}}, + }).decode(t, &set) + if set.FreshDays != httpapi.DiscoveryFresh60Days { + t.Errorf("a new set looks back %d days", set.FreshDays) + } + base := "/me/discover/sets/" + set.Id + days := httpapi.DiscoveryFresh30Days + alice.want(http.StatusOK, "PATCH", base, httpapi.UpdateDiscoverySetRequest{FreshDays: &days}).decode(t, &set) + if set.FreshDays != httpapi.DiscoveryFresh30Days { + t.Errorf("updated: %+v", set) + } + if r := alice.do("PATCH", base, map[string]int{"freshDays": 45}); r.status != http.StatusBadRequest { + t.Errorf("45 days: %d %s", r.status, r.body) + } + + // Hum's new album is fetched; Failure, a neighbor, is warmed. + var fresh httpapi.DiscoveryFresh + alice.want(http.StatusOK, "GET", base+"/fresh", nil).decode(t, &fresh) + if len(fresh.Items) != 1 || fresh.Days != 30 || fresh.NoReleases { + t.Fatalf("fresh: %+v", fresh) + } + inlet := fresh.Items[0] + if inlet.Title != "Inlet" || inlet.Kind != httpapi.DiscoveryReleaseKindAlbum || !inlet.Seed || inlet.DeezerId == nil || *inlet.DeezerId != 10 || + inlet.Cover == nil || len(inlet.Because) != 1 || inlet.Because[0] != "Hum" { + t.Errorf("Inlet: %+v", inlet) + } + if !fresh.Partial { + t.Error("not partial with Failure still to look up") + } + if _, err := rel.Get(t.Context(), musicgraph.ArtistRef{Name: "Failure"}); err != nil { + t.Fatal(err) + } + fresh = httpapi.DiscoveryFresh{} + alice.want(http.StatusOK, "GET", base+"/fresh", nil).decode(t, &fresh) + if len(fresh.Items) != 2 || fresh.Items[1].Title != "Wild Type" || fresh.Items[1].Seed { + t.Errorf("with Failure's: %+v", fresh.Items) + } + + var tracks httpapi.DiscoveryReleaseTracks + alice.want(http.StatusOK, "GET", "/me/discover/release-tracks?artist=Hum&title=Inlet&deezerId=10", nil).decode(t, &tracks) + if len(tracks.Items) != 2 || tracks.Items[0].DeezerId == nil || *tracks.Items[0].DeezerId != 101 || tracks.Items[0].Album == nil || *tracks.Items[0].Album != "Inlet" { + t.Errorf("tracks: %+v", tracks) + } + if r := alice.do("GET", "/me/discover/release-art/00000000-0000-4000-8000-000000000000?group=true", nil); r.status != http.StatusNotFound { + t.Errorf("release art without MusicBrainz: %d", r.status) + } + if r := bob.do("GET", base+"/fresh", nil); r.status != http.StatusNotFound { + t.Errorf("bob's look at alice's fresh feed: %d", r.status) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 93877a4..d625b7e 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -30,6 +30,7 @@ import ( "github.com/madeofpendletonwool/syncphony/server/internal/linernotes" "github.com/madeofpendletonwool/syncphony/server/internal/links" "github.com/madeofpendletonwool/syncphony/server/internal/lyrics" + "github.com/madeofpendletonwool/syncphony/server/internal/musicbrainz" "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" "github.com/madeofpendletonwool/syncphony/server/internal/nights" "github.com/madeofpendletonwool/syncphony/server/internal/palette" @@ -86,6 +87,9 @@ type Server struct { // Deezer plays discovery's previews and loads its covers. Nil when // Deezer is off. Deezer *musicgraph.Deezer + // MusicBrainz loads discovery's covers that Deezer doesn't have. Nil + // when MusicBrainz is off. + MusicBrainz *musicbrainz.Service // Games runs the rooms' party games. Nil turns them off. Games *games.Engine // Clips serves the games' song clips. Nil serves none. diff --git a/server/internal/musicbrainz/coverart.go b/server/internal/musicbrainz/coverart.go index bf3a888..59436d0 100644 --- a/server/internal/musicbrainz/coverart.go +++ b/server/internal/musicbrainz/coverart.go @@ -52,13 +52,20 @@ func (c *coverArt) front(ctx context.Context, ids IDs, size int) (artcache.Image } } k := artKey{release: ids.Release, size: thumb} + if ids.Release == "" { + k.release = "group:" + ids.ReleaseGroup + } if img, ok := c.cache.Get(k); ok { if len(img.Data) == 0 { - return artcache.Image{}, fmt.Errorf("cover art for release %s: %w", ids.Release, provider.ErrNotFound) + return artcache.Image{}, fmt.Errorf("cover art for release %s: %w", k.release, provider.ErrNotFound) } return img, nil } - img, err := c.fetch(ctx, fmt.Sprintf("/release/%s/front-%d", ids.Release, thumb)) + var img *artcache.Image + var err error + if ids.Release != "" { + img, err = c.fetch(ctx, fmt.Sprintf("/release/%s/front-%d", ids.Release, thumb)) + } if img == nil && err == nil && ids.ReleaseGroup != "" { img, err = c.fetch(ctx, fmt.Sprintf("/release-group/%s/front-%d", ids.ReleaseGroup, thumb)) } @@ -67,7 +74,7 @@ func (c *coverArt) front(ctx context.Context, ids IDs, size int) (artcache.Image } if img == nil { c.cache.Put(k, artcache.Image{}) - return artcache.Image{}, fmt.Errorf("cover art for release %s: %w", ids.Release, provider.ErrNotFound) + return artcache.Image{}, fmt.Errorf("cover art for release %s: %w", k.release, provider.ErrNotFound) } c.cache.Put(k, *img) return *img, nil diff --git a/server/internal/musicbrainz/releases.go b/server/internal/musicbrainz/releases.go new file mode 100644 index 0000000..a5972eb --- /dev/null +++ b/server/internal/musicbrainz/releases.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package musicbrainz + +import ( + "context" + "errors" + "fmt" + "net/url" + + "github.com/madeofpendletonwool/syncphony/server/internal/artcache" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" +) + +// What the Fresh feed (MAD-822, ADR 0017) asks MusicBrainz: an artist's +// latest release groups, and their covers. + +// maxRecent is how many of an artist's recent release groups are read. +const maxRecent = 50 + +// RecentReleaseGroups returns the release groups an artist is credited on +// that first came out from one date to another (YYYY-MM-DD): one search, +// at the rate limit. +func (s *Service) RecentReleaseGroups(ctx context.Context, artistMBID, from, to string) ([]ReleaseGroup, error) { + if artistMBID == "" { + return nil, fmt.Errorf("musicbrainz: an artist with no MBID: %w", provider.ErrNotFound) + } + q := fmt.Sprintf("arid:%s AND firstreleasedate:[%s TO %s]", quote(artistMBID), from, to) + var r struct { + ReleaseGroups []releaseGroup `json:"release-groups"` + } + err := s.mb.get(ctx, "release-group", url.Values{"query": {q}, "limit": {fmt.Sprint(maxRecent)}}, &r) + if errors.Is(err, errNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + out := make([]ReleaseGroup, 0, len(r.ReleaseGroups)) + for _, rg := range r.ReleaseGroups { + out = append(out, rg.group()) + } + return out, nil +} + +// ReleaseArt returns a release's front cover, else its release group's. +// Either may be empty. +func (s *Service) ReleaseArt(ctx context.Context, release, group string, size int) (artcache.Image, error) { + if release == "" && group == "" { + return artcache.Image{}, fmt.Errorf("cover art for no release: %w", provider.ErrNotFound) + } + return s.caa.front(ctx, IDs{Release: release, ReleaseGroup: group}, size) +} diff --git a/server/internal/musicbrainz/releases_test.go b/server/internal/musicbrainz/releases_test.go new file mode 100644 index 0000000..8a1c799 --- /dev/null +++ b/server/internal/musicbrainz/releases_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package musicbrainz_test + +import ( + "errors" + "slices" + "testing" + + "github.com/madeofpendletonwool/syncphony/server/internal/provider" +) + +func TestRecentReleaseGroups(t *testing.T) { + e := newEnv(t, -1) + e.mb.route(`/ws/2/release-group?arid:"`+artistID+`" AND firstreleasedate:[2026-07-01 TO 2026-10-05]`, "recent-release-groups.json") + rgs, err := e.svc.RecentReleaseGroups(t.Context(), artistID, "2026-07-01", "2026-10-05") + if err != nil { + t.Fatal(err) + } + var got []string + for _, rg := range rgs { + got = append(got, rg.Title+" "+rg.FirstReleased+" "+rg.Kind()) + } + if want := []string{"Highpass 2026-09-12 album", "Live at Leeds Again 2026-08 live"}; !slices.Equal(got, want) { + t.Errorf("release groups = %q, want %q", got, want) + } + // Nothing lately is nothing, not an error. + rgs, err = e.svc.RecentReleaseGroups(t.Context(), artistID, "2020-01-01", "2020-03-01") + if err != nil || len(rgs) != 0 { + t.Errorf("nothing lately: %v, %v", rgs, err) + } + if _, err := e.svc.RecentReleaseGroups(t.Context(), "", "2026-07-01", "2026-10-05"); !errors.Is(err, provider.ErrNotFound) { + t.Errorf("no MBID: %v", err) + } +} + +func TestReleaseArtOfAGroup(t *testing.T) { + e := newEnv(t, -1) + e.mb.cover("/release-group/"+lowPassRG+"/front-250", 250) + img, err := e.svc.ReleaseArt(t.Context(), "", lowPassRG, 200) + if err != nil { + t.Fatal(err) + } + if img.ContentType != "image/png" || len(img.Data) == 0 { + t.Errorf("image: %s, %d bytes", img.ContentType, len(img.Data)) + } + // Only the group was asked for: there's no release to ask about. + for _, r := range e.mb.requests { + if r.URL.Path == "/release//front-250" { + t.Error("asked for a release with no MBID") + } + } + if _, err := e.svc.ReleaseArt(t.Context(), "", "", 250); !errors.Is(err, provider.ErrNotFound) { + t.Errorf("no release: %v", err) + } +} diff --git a/server/internal/musicbrainz/testdata/recent-release-groups.json b/server/internal/musicbrainz/testdata/recent-release-groups.json new file mode 100644 index 0000000..fdc80fb --- /dev/null +++ b/server/internal/musicbrainz/testdata/recent-release-groups.json @@ -0,0 +1,4 @@ +{"created":"2026-10-05T12:00:00Z","count":2,"offset":0,"release-groups":[ + {"id":"cc000000-0000-4000-8000-000000000001","title":"Highpass","primary-type":"Album","first-release-date":"2026-09-12"}, + {"id":"cc000000-0000-4000-8000-000000000002","title":"Live at Leeds Again","primary-type":"Album","secondary-types":["Live"],"first-release-date":"2026-08"} +]} diff --git a/server/internal/musicgraph/deezer.go b/server/internal/musicgraph/deezer.go index fb0dfdb..1c27f1c 100644 --- a/server/internal/musicgraph/deezer.go +++ b/server/internal/musicgraph/deezer.go @@ -131,23 +131,10 @@ func (d *Deezer) Artist(ctx context.Context, a ArtistRef) (Artist, error) { if a.Name == "" { return Artist{}, provider.ErrUnsupported } - var found struct { - Data []dzArtist `json:"data"` - } - if err := d.get(ctx, "/search/artist", url.Values{"q": {a.Name}, "limit": {"10"}}, &found); err != nil { + id, err := d.findArtist(ctx, a.Name) + if err != nil { return Artist{}, err } - // The same name can be more than one artist: take the best known. - want := match.Simplify(a.Name) - var id dzArtist - for _, c := range found.Data { - if match.Simplify(c.Name) == want && c.Fans > id.Fans { - id = c - } - } - if id.ID == 0 { - return Artist{}, fmt.Errorf("deezer: artist %q: %w", a.Name, provider.ErrNotFound) - } path := "/artist/" + strconv.FormatInt(id.ID, 10) out := Artist{Ref: a, Fans: id.Fans, Picture: cmp.Or(id.MD5Image, imageID(id.Picture))} @@ -181,6 +168,28 @@ func (d *Deezer) Artist(ctx context.Context, a ArtistRef) (Artist, error) { return out, nil } +// findArtist finds an artist on Deezer by name. The same name can be +// more than one artist: it takes the best known. +func (d *Deezer) findArtist(ctx context.Context, name string) (dzArtist, error) { + var found struct { + Data []dzArtist `json:"data"` + } + if err := d.get(ctx, "/search/artist", url.Values{"q": {name}, "limit": {"10"}}, &found); err != nil { + return dzArtist{}, err + } + want := match.Simplify(name) + var id dzArtist + for _, c := range found.Data { + if match.Simplify(c.Name) == want && c.Fans > id.Fans { + id = c + } + } + if id.ID == 0 { + return dzArtist{}, fmt.Errorf("deezer: artist %q: %w", name, provider.ErrNotFound) + } + return id, nil +} + // Track implements Source: the song by ISRC, or else by searching, for // its tempo, popularity and release date. func (d *Deezer) Track(ctx context.Context, s SongRef) (Track, error) { diff --git a/server/internal/musicgraph/deezer_media.go b/server/internal/musicgraph/deezer_media.go index f5d648b..3a6c755 100644 --- a/server/internal/musicgraph/deezer_media.go +++ b/server/internal/musicgraph/deezer_media.go @@ -35,12 +35,13 @@ type DeezerTrack struct { Rank float64 // HasPreview is whether Deezer has a 30-second preview of it. HasPreview bool + ISRC string } func (t dzTrack) public() DeezerTrack { return DeezerTrack{ ID: t.ID, Title: t.Title, Artist: t.Artist.Name, Album: t.Album.Title, Cover: t.cover(), - Rank: min(t.Rank/deezerTopRank, 1), HasPreview: t.Preview != "", + Rank: min(t.Rank/deezerTopRank, 1), HasPreview: t.Preview != "", ISRC: t.ISRC, } } diff --git a/server/internal/musicgraph/http.go b/server/internal/musicgraph/http.go index 524f471..e058433 100644 --- a/server/internal/musicgraph/http.go +++ b/server/internal/musicgraph/http.go @@ -86,6 +86,11 @@ func (c *client) backOff(d time.Duration) { // A throttle, from the status or from read (Last.fm's and Deezer's come in // the body), is waited out and tried once more, within requestTimeout. func (c *client) get(ctx context.Context, u string, params url.Values, read func(status int, body io.Reader) error) error { + return c.getUpTo(ctx, u, params, maxResponse, read) +} + +// getUpTo is get, reading up to limit bytes of the body. +func (c *client) getUpTo(ctx context.Context, u string, params url.Values, limit int64, read func(status int, body io.Reader) error) error { ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() if len(params) > 0 { @@ -96,7 +101,7 @@ func (c *client) get(ctx context.Context, u string, params url.Values, read func if err = c.wait(ctx); err != nil { return err } - err = c.once(ctx, u, read) + err = c.once(ctx, u, limit, read) if !errors.Is(err, provider.ErrRateLimited) { return err } @@ -104,7 +109,7 @@ func (c *client) get(ctx context.Context, u string, params url.Values, read func return err } -func (c *client) once(ctx context.Context, u string, read func(status int, body io.Reader) error) error { +func (c *client) once(ctx context.Context, u string, limit int64, read func(status int, body io.Reader) error) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return err @@ -128,7 +133,7 @@ func (c *client) once(ctx context.Context, u string, read func(status int, body c.backOff(d) return fmt.Errorf("%s: %w", c.name, &provider.RateLimitError{RetryAfter: d}) } - return read(resp.StatusCode, io.LimitReader(resp.Body, maxResponse)) + return read(resp.StatusCode, io.LimitReader(resp.Body, limit)) } // heed follows a source's rate limit headers (ListenBrainz sends them): diff --git a/server/internal/musicgraph/releases.go b/server/internal/musicgraph/releases.go new file mode 100644 index 0000000..7ac289f --- /dev/null +++ b/server/internal/musicgraph/releases.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package musicgraph + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + + "github.com/madeofpendletonwool/syncphony/server/internal/match" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" +) + +// What the Fresh feed (MAD-822, ADR 0017) asks Deezer and ListenBrainz: +// artists' releases, and what came out lately. + +// maxAlbums is how many of an artist's releases are read from Deezer: +// it lists albums first, then EPs and singles, newest first within each. +const maxAlbums = 100 + +// DeezerAlbum is a release on Deezer: an album, EP or single. +type DeezerAlbum struct { + ID int64 + Title string + // Date is when it came out, YYYY-MM-DD. + Date string + // Type is Deezer's record type: album, ep, single or compile. + Type string + // Cover is its cover's image ID. + Cover string +} + +type dzAlbum struct { + ID int64 `json:"id"` + Title string `json:"title"` + ReleaseDate string `json:"release_date"` + RecordType string `json:"record_type"` + MD5Image string `json:"md5_image"` + CoverMedium string `json:"cover_medium"` + Artist struct { + Name string `json:"name"` + } `json:"artist"` +} + +func (a dzAlbum) public() DeezerAlbum { + return DeezerAlbum{ID: a.ID, Title: a.Title, Date: a.ReleaseDate, Type: a.RecordType, Cover: cmp.Or(a.MD5Image, imageID(a.CoverMedium))} +} + +// ArtistAlbums lists an artist's releases on Deezer, found by name: two +// requests. It's provider.ErrNotFound if Deezer doesn't know them. +func (d *Deezer) ArtistAlbums(ctx context.Context, name string) ([]DeezerAlbum, error) { + if name == "" { + return nil, fmt.Errorf("deezer: an artist with no name: %w", provider.ErrNotFound) + } + a, err := d.findArtist(ctx, name) + if err != nil { + return nil, err + } + var found struct { + Data []dzAlbum `json:"data"` + } + if err := d.get(ctx, "/artist/"+strconv.FormatInt(a.ID, 10)+"/albums", url.Values{"limit": {strconv.Itoa(maxAlbums)}}, &found); err != nil { + return nil, err + } + out := make([]DeezerAlbum, 0, len(found.Data)) + for _, al := range found.Data { + if al.ID != 0 && al.Title != "" { + out = append(out, al.public()) + } + } + return out, nil +} + +// FindAlbum finds a release on Deezer by its artist and title, matched +// the way cross-service matching matches titles. It's +// provider.ErrNotFound if nothing matches. +func (d *Deezer) FindAlbum(ctx context.Context, artist, title string) (DeezerAlbum, error) { + want, variants := match.Title(title) + if want == "" || artist == "" { + return DeezerAlbum{}, fmt.Errorf("deezer: a release with no title or artist: %w", provider.ErrNotFound) + } + var found struct { + Data []dzAlbum `json:"data"` + } + q := fmt.Sprintf("artist:%q album:%q", artist, title) + if err := d.get(ctx, "/search/album", url.Values{"q": {q}, "limit": {"10"}}, &found); err != nil { + return DeezerAlbum{}, err + } + who := match.Simplify(artist) + for _, al := range found.Data { + // "Bleeds (Deluxe)" is "Bleeds", but "Bleeds (Live)" isn't. + got, vs := match.Title(al.Title) + if got == want && slices.Equal(vs, variants) && match.Simplify(al.Artist.Name) == who { + return al.public(), nil + } + } + return DeezerAlbum{}, fmt.Errorf("deezer: release %q by %q: %w", title, artist, provider.ErrNotFound) +} + +// AlbumTracks lists a release's songs on Deezer, in order. +func (d *Deezer) AlbumTracks(ctx context.Context, id int64) ([]DeezerTrack, error) { + if id <= 0 { + return nil, fmt.Errorf("deezer: album %d: %w", id, provider.ErrNotFound) + } + var found struct { + Data []dzTrack `json:"data"` + } + if err := d.get(ctx, "/album/"+strconv.FormatInt(id, 10)+"/tracks", url.Values{"limit": {"200"}}, &found); err != nil { + return nil, err + } + out := make([]DeezerTrack, 0, len(found.Data)) + for _, t := range found.Data { + if t.ID != 0 && t.Title != "" { + out = append(out, t.public()) + } + } + return out, nil +} + +// FreshRelease is a release that came out lately, as ListenBrainz lists +// them. +type FreshRelease struct { + Title string + // Artist is how the release credits its artists; ArtistMBIDs, who + // they are. + Artist string + ArtistMBIDs []string + // MBID is its release group, Release the release, and CoverRelease + // the release the Cover Art Archive has a cover for, if any. + MBID, Release, CoverRelease string + // Date is when it came out, YYYY-MM-DD. + Date string + // PrimaryType is "Album", "Single", "EP"...; SecondaryType refines + // it ("Compilation", "Live"). + PrimaryType, SecondaryType string +} + +// maxFresh bounds reading ListenBrainz's fresh releases: about 12 MB for +// 90 days. +const maxFresh = 64 << 20 + +// FreshReleases lists what came out in the last days (90 at most), on +// all of ListenBrainz: one big request, to be filtered by artist after. +func (l *ListenBrainz) FreshReleases(ctx context.Context, days int) ([]FreshRelease, error) { + params := url.Values{"days": {strconv.Itoa(max(1, min(days, 90)))}, "past": {"true"}, "future": {"false"}, "sort": {"release_date"}} + var out []FreshRelease + err := l.c.getUpTo(ctx, l.base+"/1/explore/fresh-releases/", params, maxFresh, func(status int, body io.Reader) error { + if status != http.StatusOK { + return fmt.Errorf("listenbrainz: fresh releases: HTTP %d: %w", status, provider.ErrUnavailable) + } + var r struct { + Payload struct { + Releases []struct { + Artist string `json:"artist_credit_name"` + ArtistMBIDs []string `json:"artist_mbids"` + Title string `json:"release_name"` + MBID string `json:"release_group_mbid"` + Release string `json:"release_mbid"` + CAAID *int64 `json:"caa_id"` + CAARelease string `json:"caa_release_mbid"` + Date string `json:"release_date"` + PrimaryType string `json:"release_group_primary_type"` + SecondaryType string `json:"release_group_secondary_type"` + } `json:"releases"` + } `json:"payload"` + } + if err := json.NewDecoder(body).Decode(&r); err != nil { + return fmt.Errorf("listenbrainz: fresh releases: %w: %w", provider.ErrUnavailable, err) + } + out = make([]FreshRelease, 0, len(r.Payload.Releases)) + for _, x := range r.Payload.Releases { + if x.Title == "" || x.Artist == "" { + continue + } + f := FreshRelease{ + Title: x.Title, Artist: x.Artist, ArtistMBIDs: x.ArtistMBIDs, MBID: x.MBID, Release: x.Release, + Date: x.Date, PrimaryType: x.PrimaryType, SecondaryType: x.SecondaryType, + } + if x.CAAID != nil { + f.CoverRelease = x.CAARelease + } + out = append(out, f) + } + return nil + }) + return out, err +} diff --git a/server/internal/musicgraph/releases_test.go b/server/internal/musicgraph/releases_test.go new file mode 100644 index 0000000..7027b49 --- /dev/null +++ b/server/internal/musicgraph/releases_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package musicgraph_test + +import ( + "errors" + "testing" + + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" +) + +func TestDeezerArtistAlbums(t *testing.T) { + e := newEnv(t) + e.http.routes["/deezer/search/artist?Big Thief"] = `{"data":[{"id":7,"name":"Big Thief","nb_fan":73053},{"id":8,"name":"Big Chief","nb_fan":486}]}` + e.http.routes["/deezer/artist/7/albums"] = `{"data":[ + {"id":804726201,"title":"Double Infinity","release_date":"2025-09-05","record_type":"album","md5_image":"` + coverID + `"}, + {"id":223922482,"title":"Live at The Bunker Studio","release_date":"2021-05-11","record_type":"ep","cover_medium":"https://e-cdns-images.dzcdn.net/images/cover/` + coverID + `/250x250-000000-80-0-0.jpg"}, + {"id":0,"title":"no ID"}]}` + d := musicgraph.NewDeezer(musicgraph.DeezerOptions{BaseURL: e.http.URL + "/deezer", Interval: -1}) + got, err := d.ArtistAlbums(t.Context(), "Big Thief") + if err != nil { + t.Fatal(err) + } + want := []musicgraph.DeezerAlbum{ + {ID: 804726201, Title: "Double Infinity", Date: "2025-09-05", Type: "album", Cover: coverID}, + {ID: 223922482, Title: "Live at The Bunker Studio", Date: "2021-05-11", Type: "ep", Cover: coverID}, + } + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("albums:\n got %+v\nwant %+v", got, want) + } + if _, err := d.ArtistAlbums(t.Context(), "Nobody"); !errors.Is(err, provider.ErrNotFound) { + t.Errorf("an unknown artist: %v", err) + } +} + +func TestDeezerFindAlbumAndItsTracks(t *testing.T) { + e := newEnv(t) + e.http.routes[`/deezer/search/album?artist:"Wednesday" album:"Bleeds"`] = `{"data":[ + {"id":1,"title":"Bleeds (Live)","artist":{"name":"Wednesday"}}, + {"id":2,"title":"Bleeds (Deluxe)","record_type":"album","release_date":"2025-09-19","md5_image":"` + coverID + `","artist":{"name":"Wednesday"}}]}` + e.http.routes["/deezer/album/2/tracks"] = `{"data":[ + {"id":21,"title":"Reality TV Argument Bleeds","isrc":"US0000000001","preview":"https://cdnt-preview.dzcdn.net/a.mp3","artist":{"name":"Wednesday"}}, + {"id":22,"title":"Elderberry Wine","artist":{"name":"Wednesday"}}]}` + d := musicgraph.NewDeezer(musicgraph.DeezerOptions{BaseURL: e.http.URL + "/deezer", Interval: -1}) + al, err := d.FindAlbum(t.Context(), "Wednesday", "Bleeds") + if err != nil { + t.Fatal(err) + } + if al.ID != 2 || al.Cover != coverID || al.Date != "2025-09-19" { + t.Errorf("album: %+v", al) + } + tracks, err := d.AlbumTracks(t.Context(), al.ID) + if err != nil { + t.Fatal(err) + } + if len(tracks) != 2 || tracks[0].ID != 21 || !tracks[0].HasPreview || tracks[0].ISRC != "US0000000001" || tracks[1].HasPreview { + t.Errorf("tracks: %+v", tracks) + } + if _, err := d.FindAlbum(t.Context(), "Wednesday", "Twin Plagues"); !errors.Is(err, provider.ErrNotFound) { + t.Errorf("an album Deezer doesn't have: %v", err) + } +} + +func TestListenBrainzFreshReleases(t *testing.T) { + e := newEnv(t) + e.http.routes["/lb/1/explore/fresh-releases/"] = `{"payload":{"releases":[ + {"artist_credit_name":"Wednesday","artist_mbids":["aa"],"release_name":"Bleeds","release_group_mbid":"rg1","release_mbid":"r1", + "caa_id":123,"caa_release_mbid":"r1","release_date":"2026-09-19","release_group_primary_type":"Album","release_group_secondary_type":null}, + {"artist_credit_name":"MJ Lenderman","artist_mbids":["bb"],"release_name":"Live at the Grey Eagle","release_group_mbid":"rg2","release_mbid":"r2", + "caa_id":null,"caa_release_mbid":null,"release_date":"2026-08-01","release_group_primary_type":"Album","release_group_secondary_type":"Live"}, + {"artist_credit_name":"","release_name":"nobody's"}],"total_count":3}}` + lb := musicgraph.NewListenBrainz(musicgraph.ListenBrainzOptions{BaseURL: e.http.URL + "/lb", LabsURL: e.http.URL + "/labs", Interval: -1}) + got, err := lb.FreshReleases(t.Context(), 365) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("releases: %+v", got) + } + if r := got[0]; r.Title != "Bleeds" || r.Artist != "Wednesday" || r.ArtistMBIDs[0] != "aa" || r.MBID != "rg1" || r.CoverRelease != "r1" || r.Date != "2026-09-19" || r.PrimaryType != "Album" { + t.Errorf("Bleeds: %+v", r) + } + if r := got[1]; r.CoverRelease != "" || r.SecondaryType != "Live" { + t.Errorf("a live album without a cover: %+v", r) + } + req := e.http.requests[len(e.http.requests)-1] + if q := req.URL.Query(); q.Get("days") != "90" || q.Get("future") != "false" { + t.Errorf("asked %s", req.URL) + } +} diff --git a/server/internal/releases/releases.go b/server/internal/releases/releases.go new file mode 100644 index 0000000..6daaaa4 --- /dev/null +++ b/server/internal/releases/releases.go @@ -0,0 +1,566 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package releases finds artists' recent releases (MAD-822, ADR 0017): +// the albums, EPs and singles they put out in the last few months, for +// discovery's Fresh feed. +// +// It asks three sources and merges their answers by artist and title: +// ListenBrainz's fresh releases (one big list of everything lately, read +// every few hours and filtered by artist), MusicBrainz's release groups by +// first-release date, and Deezer's albums, which carry covers and songs +// with previews. Each artist's answer is cached a day. A background worker +// warms the cache one artist at a time, so the sources' rate limits hold +// (MusicBrainz's one request a second is shared with the whole server). +package releases + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "slices" + "strings" + "sync" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/library" + "github.com/madeofpendletonwool/syncphony/server/internal/match" + "github.com/madeofpendletonwool/syncphony/server/internal/musicbrainz" + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// Kinds of release. Compilations and live albums are told apart from +// studio albums, so a feed can rank them lower. +const ( + KindAlbum = "album" + KindEP = "ep" + KindSingle = "single" + KindCompilation = "compilation" + KindLive = "live" +) + +// Release is one of an artist's releases. +type Release struct { + // Key is how releases are compared: library.ReleaseKey of the artist + // it was looked up for and its title. + Key string `json:"key"` + Title string `json:"title"` + // Artist is who it's credited to. + Artist string `json:"artist"` + // Date is when it first came out: YYYY-MM-DD, or YYYY-MM if that's + // all that's known. + Date string `json:"date"` + Kind string `json:"kind"` + // MBID is its MusicBrainz release group, CoverRelease a release of + // it the Cover Art Archive has a cover for. + MBID string `json:"mbid,omitempty"` + CoverRelease string `json:"coverRelease,omitempty"` + // DeezerID is the release on Deezer, and Cover its cover's image ID + // there, when Deezer has it. + DeezerID int64 `json:"deezerId,omitempty"` + Cover string `json:"cover,omitempty"` + // Sources are the sources that listed it. + Sources []string `json:"sources"` +} + +// Day is when it came out, as a time: the first of the month if only the +// month is known. +func (r Release) Day() (time.Time, bool) { + for _, layout := range []string{time.DateOnly, "2006-01"} { + if t, err := time.Parse(layout, r.Date); err == nil { + return t, true + } + } + return time.Time{}, false +} + +// Sources. + +// Deezer lists artists' releases and their songs. musicgraph.Deezer is one. +type Deezer interface { + ArtistAlbums(ctx context.Context, name string) ([]musicgraph.DeezerAlbum, error) + FindAlbum(ctx context.Context, artist, title string) (musicgraph.DeezerAlbum, error) + AlbumTracks(ctx context.Context, id int64) ([]musicgraph.DeezerTrack, error) +} + +// Fresh lists everything that came out lately. musicgraph.ListenBrainz is one. +type Fresh interface { + FreshReleases(ctx context.Context, days int) ([]musicgraph.FreshRelease, error) +} + +// MusicBrainz lists an artist's release groups by date. musicbrainz.Service is one. +type MusicBrainz interface { + RecentReleaseGroups(ctx context.Context, artistMBID, from, to string) ([]musicbrainz.ReleaseGroup, error) +} + +// How far back, how long things are kept, and how much is asked. +const ( + // Lookback is how far back releases are kept: the longest Fresh + // window, and some. + Lookback = 100 * 24 * time.Hour + // freshDays is how far back ListenBrainz's list goes: as far as it + // will. + freshDays = 90 + queueSize = 512 + // maxTracks is how many releases' songs are remembered. + maxTracks = 256 + tracksFor = 6 * time.Hour +) + +// Options configure a Service. Every source is optional. +type Options struct { + Deezer Deezer + ListenBrainz Fresh + MusicBrainz MusicBrainz + // TTL is how long an artist's releases are kept. Default a day. + TTL time.Duration + // MissTTL is how long an answer some source failed to give is kept. + // Default an hour. + MissTTL time.Duration + // FreshEvery is how often ListenBrainz's list is read again. Default + // 6 hours. + FreshEvery time.Duration + // Timeout bounds asking the sources about one artist. Default 45s: + // MusicBrainz may be busy with others. + Timeout time.Duration + Now func() time.Time +} + +// Service finds artists' recent releases, from the cache or the sources. +// Call Run to warm the cache in the background. +type Service struct { + db *store.Store + opts Options + + jobs chan musicgraph.ArtistRef + mu sync.Mutex + pending map[string]bool + flights map[string]chan struct{} + + // fresh is ListenBrainz's list, by artist. + freshMu sync.Mutex + fresh *freshIndex + + tracksMu sync.Mutex + tracks map[string]tracksEntry +} + +type freshIndex struct { + at time.Time + byMBID map[string][]musicgraph.FreshRelease + byName map[string][]musicgraph.FreshRelease +} + +type tracksEntry struct { + at time.Time + album musicgraph.DeezerAlbum + tracks []musicgraph.DeezerTrack +} + +// New returns a Service. +func New(db *store.Store, opts Options) *Service { + if opts.TTL <= 0 { + opts.TTL = 24 * time.Hour + } + if opts.MissTTL <= 0 { + opts.MissTTL = time.Hour + } + if opts.FreshEvery <= 0 { + opts.FreshEvery = 6 * time.Hour + } + if opts.Timeout <= 0 { + opts.Timeout = 45 * time.Second + } + if opts.Now == nil { + opts.Now = store.Now + } + return &Service{ + db: db, opts: opts, jobs: make(chan musicgraph.ArtistRef, queueSize), + pending: map[string]bool{}, flights: map[string]chan struct{}{}, tracks: map[string]tracksEntry{}, + } +} + +// On reports whether any source is on. +func (s *Service) On() bool { + return s.opts.Deezer != nil || s.opts.ListenBrainz != nil || s.opts.MusicBrainz != nil +} + +func key(a musicgraph.ArtistRef) string { return match.Simplify(a.Name) } + +// Cached returns an artist's releases from the cache, without asking the +// sources. ok is false if they aren't cached. +func (s *Service) Cached(ctx context.Context, a musicgraph.ArtistRef) ([]Release, bool, error) { + k := key(a) + if k == "" { + return nil, true, nil + } + row, err := s.db.GetArtistReleases(ctx, store.GetArtistReleasesParams{Key: k, Now: s.opts.Now()}) + switch { + case store.IsNotFound(err): + return nil, false, nil + case err != nil: + return nil, false, err + } + var out []Release + if err := json.Unmarshal([]byte(row.Releases), &out); err != nil { + return nil, false, fmt.Errorf("releases: reading %q: %w", k, err) + } + return out, true, nil +} + +// Get returns an artist's releases from the last Lookback, newest first: +// from the cache or, on a miss, the sources. Only one fetch of an artist +// runs at a time; others wait for it. +func (s *Service) Get(ctx context.Context, a musicgraph.ArtistRef) ([]Release, error) { + if out, ok, err := s.Cached(ctx, a); ok || err != nil { + return out, err + } + k := key(a) + s.mu.Lock() + wait, busy := s.flights[k] + if !busy { + s.flights[k] = make(chan struct{}) + } + s.mu.Unlock() + if busy { + select { + case <-wait: + case <-ctx.Done(): + return nil, ctx.Err() + } + if out, ok, err := s.Cached(ctx, a); ok || err != nil { + return out, err + } + return nil, fmt.Errorf("releases: %q: %w", a.Name, provider.ErrUnavailable) + } + defer func() { + s.mu.Lock() + close(s.flights[k]) + delete(s.flights, k) + s.mu.Unlock() + }() + return s.fetch(ctx, a) +} + +func (s *Service) fetch(ctx context.Context, a musicgraph.ArtistRef) ([]Release, error) { + fctx, cancel := context.WithTimeout(ctx, s.opts.Timeout) + defer cancel() + now := s.opts.Now() + from, to := now.Add(-Lookback).Format(time.DateOnly), now.Format(time.DateOnly) + m := merger{artist: a.Name, from: from, to: to, byKey: map[string]int{}} + // failed is whether a source that might have known failed, so the + // answer is kept only briefly; answered, whether any source answered. + failed, answered := false, false + note := func(source string, err error) { + if err == nil || errors.Is(err, provider.ErrNotFound) { + answered = true + return + } + failed = true + slog.Debug("releases: asking about an artist", "source", source, "artist", a.Name, "err", err) + } + + if s.opts.ListenBrainz != nil { + ix, err := s.freshList(fctx) + note("listenbrainz", err) + if ix != nil { + // By MBID, and by name for releases credited without one. + mine := ix.byName[library.ArtistKey(a.Name)] + if a.MBID != "" { + mine = append(slices.Clip(ix.byMBID[strings.ToLower(a.MBID)]), mine...) + } + for _, f := range mine { + m.add(Release{ + Title: f.Title, Artist: f.Artist, Date: f.Date, Kind: kindOf(f.PrimaryType, splitTypes(f.SecondaryType), f.Title), + MBID: f.MBID, CoverRelease: f.CoverRelease, + }, "listenbrainz") + } + } + } + if s.opts.MusicBrainz != nil && a.MBID != "" { + rgs, err := s.opts.MusicBrainz.RecentReleaseGroups(fctx, a.MBID, from, to) + if errors.Is(err, provider.ErrRateLimited) { + // MusicBrainz is busy with the rest of the server: its client + // backs off, so the second try waits its turn. + rgs, err = s.opts.MusicBrainz.RecentReleaseGroups(fctx, a.MBID, from, to) + } + note("musicbrainz", err) + for _, rg := range rgs { + if len(rg.FirstReleased) < len("2006-01") { + continue // a year alone doesn't say if it's recent + } + m.add(Release{ + Title: rg.Title, Artist: a.Name, Date: rg.FirstReleased, Kind: kindOf(rg.PrimaryType, rg.SecondaryTypes, rg.Title), MBID: rg.MBID, + }, "musicbrainz") + } + } + if s.opts.Deezer != nil { + albums, err := s.opts.Deezer.ArtistAlbums(fctx, a.Name) + note("deezer", err) + for _, al := range firstEditions(a.Name, albums) { + m.add(Release{ + Title: al.Title, Artist: a.Name, Date: al.Date, Kind: deezerKind(al.Type, al.Title), DeezerID: al.ID, Cover: al.Cover, + }, "deezer") + } + } + if !answered && !failed { + return nil, nil // no source is on + } + out := m.releases() + if !answered { + return nil, fmt.Errorf("releases: %q: every source that might know failed: %w", a.Name, provider.ErrUnavailable) + } + ttl := s.opts.TTL + if failed { + ttl = s.opts.MissTTL + } + if err := s.put(ctx, a, out, ttl); err != nil { + return nil, err + } + return out, nil +} + +func (s *Service) put(ctx context.Context, a musicgraph.ArtistRef, rs []Release, ttl time.Duration) error { + if rs == nil { + rs = []Release{} + } + raw, err := json.Marshal(rs) + if err != nil { + return err + } + now := s.opts.Now() + return s.db.PutArtistReleases(ctx, store.PutArtistReleasesParams{ + Key: key(a), Name: a.Name, Releases: string(raw), FetchedAt: now, ExpiresAt: now.Add(ttl), + }) +} + +// freshList returns ListenBrainz's list of what came out lately, read +// again every FreshEvery. When reading it fails, the last one is used. +func (s *Service) freshList(ctx context.Context) (*freshIndex, error) { + s.freshMu.Lock() + defer s.freshMu.Unlock() + now := s.opts.Now() + if s.fresh != nil && now.Sub(s.fresh.at) < s.opts.FreshEvery { + return s.fresh, nil + } + list, err := s.opts.ListenBrainz.FreshReleases(ctx, freshDays) + if err != nil { + if s.fresh != nil { + // Try again in a while, not with every artist. + s.fresh.at = now.Add(-s.opts.FreshEvery + 15*time.Minute) + } + return s.fresh, err + } + ix := &freshIndex{at: now, byMBID: map[string][]musicgraph.FreshRelease{}, byName: map[string][]musicgraph.FreshRelease{}} + for _, f := range list { + for _, id := range f.ArtistMBIDs { + id = strings.ToLower(id) + ix.byMBID[id] = append(ix.byMBID[id], f) + } + k := library.ArtistKey(f.Artist) + ix.byName[k] = append(ix.byName[k], f) + } + s.fresh = ix + return ix, nil +} + +// firstEditions keeps each of an artist's releases on Deezer once, as +// its first edition: Deezer dates a deluxe edition or a reissue to when it +// came out, which would make an old album look new. +func firstEditions(artist string, albums []musicgraph.DeezerAlbum) []musicgraph.DeezerAlbum { + first := map[string]int{} + var out []musicgraph.DeezerAlbum + for _, al := range albums { + k := library.ReleaseKey(artist, al.Title) + i, ok := first[k] + switch { + case !ok: + first[k] = len(out) + out = append(out, al) + case al.Date != "" && (out[i].Date == "" || al.Date < out[i].Date): + out[i] = al + } + } + return out +} + +// merger merges the sources' releases of one artist by title. They're +// added ListenBrainz and MusicBrainz first: the first to list a release +// says what kind it is and who it's credited to, and MusicBrainz's types +// say more than Deezer's. +type merger struct { + artist string + from, to string + byKey map[string]int + out []Release +} + +func (m *merger) add(r Release, source string) { + r.Date = r.Date[:min(len(r.Date), len(time.DateOnly))] + if r.Title == "" || len(r.Date) < len("2006-01") || r.Date < m.from[:len(r.Date)] || r.Date > m.to { + return + } + r.Key = library.ReleaseKey(m.artist, r.Title) + i, ok := m.byKey[r.Key] + if !ok { + r.Sources = []string{source} + m.byKey[r.Key] = len(m.out) + m.out = append(m.out, r) + return + } + cur := &m.out[i] + if !slices.Contains(cur.Sources, source) { + cur.Sources = append(cur.Sources, source) + } + // The most precise date; of two as precise, the first. + if len(r.Date) > len(cur.Date) || (len(r.Date) == len(cur.Date) && r.Date < cur.Date) { + cur.Date = r.Date + } + cur.MBID = cmp.Or(cur.MBID, r.MBID) + cur.CoverRelease = cmp.Or(cur.CoverRelease, r.CoverRelease) + cur.DeezerID = cmp.Or(cur.DeezerID, r.DeezerID) + cur.Cover = cmp.Or(cur.Cover, r.Cover) +} + +// releases are the merged releases, newest first. +func (m *merger) releases() []Release { + slices.SortStableFunc(m.out, func(a, b Release) int { + if c := cmp.Compare(b.Date, a.Date); c != 0 { + return c + } + return cmp.Compare(a.Title, b.Title) + }) + return m.out +} + +// kindOf names a release from its MusicBrainz types, and its title: a +// "(Live)" album is live whatever it's typed as. +func kindOf(primary string, secondary []string, title string) string { + k := musicbrainz.Kind(primary, secondary) + if k == KindAlbum && isLive(title) { + return KindLive + } + return k +} + +func splitTypes(s string) []string { + if s == "" { + return nil + } + return strings.Split(s, " + ") +} + +// deezerKind names a release from Deezer's record type and its title. +func deezerKind(recordType, title string) string { + switch { + case recordType == "compile": + return KindCompilation + case isLive(title): + return KindLive + case recordType == "ep": + return KindEP + case recordType == "single": + return KindSingle + } + return KindAlbum +} + +func isLive(title string) bool { + _, variants := match.Title(title) + return slices.Contains(variants, "live") || strings.HasPrefix(match.Simplify(title), "live at ") || strings.HasPrefix(match.Simplify(title), "live in ") +} + +// Tracks lists a release's songs on Deezer, with what Deezer calls the +// release: by its Deezer ID, else found by artist and title. They're +// remembered a while. +func (s *Service) Tracks(ctx context.Context, artist, title string, deezerID int64) (musicgraph.DeezerAlbum, []musicgraph.DeezerTrack, error) { + if s.opts.Deezer == nil { + return musicgraph.DeezerAlbum{}, nil, fmt.Errorf("releases: Deezer is off: %w", provider.ErrNotFound) + } + k := library.ReleaseKey(artist, title) + if deezerID != 0 { + k = fmt.Sprint(deezerID) + } + now := s.opts.Now() + s.tracksMu.Lock() + e, ok := s.tracks[k] + s.tracksMu.Unlock() + if ok && now.Sub(e.at) < tracksFor { + return e.album, e.tracks, nil + } + album := musicgraph.DeezerAlbum{ID: deezerID, Title: title} + if deezerID == 0 { + var err error + if album, err = s.opts.Deezer.FindAlbum(ctx, artist, title); err != nil { + return musicgraph.DeezerAlbum{}, nil, err + } + } + tracks, err := s.opts.Deezer.AlbumTracks(ctx, album.ID) + if err != nil { + return musicgraph.DeezerAlbum{}, nil, err + } + s.tracksMu.Lock() + if len(s.tracks) >= maxTracks { + clear(s.tracks) + } + s.tracks[k] = tracksEntry{at: now, album: album, tracks: tracks} + s.tracksMu.Unlock() + return album, tracks, nil +} + +// Warm asks for artists' releases to be fetched in the background. Ones +// already waiting are skipped, and so is everything once the queue is +// full. +func (s *Service) Warm(as ...musicgraph.ArtistRef) { + if !s.On() { + return + } + s.mu.Lock() + defer s.mu.Unlock() + for _, a := range as { + k := key(a) + if k == "" || s.pending[k] { + continue + } + select { + case s.jobs <- a: + s.pending[k] = true + default: + slog.Debug("releases: warm queue full, dropping", "artist", a.Name) + return + } + } +} + +// Run warms the cache, one artist at a time, and deletes what's expired +// hourly, until ctx is done. +func (s *Service) Run(ctx context.Context) { + sweep := time.NewTicker(time.Hour) + defer sweep.Stop() + for { + select { + case <-ctx.Done(): + return + case <-sweep.C: + if err := s.Sweep(ctx); err != nil && ctx.Err() == nil { + slog.Warn("releases: sweeping expired releases", "err", err) + } + case a := <-s.jobs: + if _, err := s.Get(ctx, a); err != nil && ctx.Err() == nil { + slog.Debug("releases: warming an artist", "artist", a.Name, "err", err) + } + s.mu.Lock() + delete(s.pending, key(a)) + s.mu.Unlock() + } + } +} + +// Sweep deletes expired releases. +func (s *Service) Sweep(ctx context.Context) error { + return s.db.DeleteExpiredArtistReleases(ctx, s.opts.Now()) +} diff --git a/server/internal/releases/releases_test.go b/server/internal/releases/releases_test.go new file mode 100644 index 0000000..83401c2 --- /dev/null +++ b/server/internal/releases/releases_test.go @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package releases_test + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "slices" + "sync" + "testing" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/match" + "github.com/madeofpendletonwool/syncphony/server/internal/musicbrainz" + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/releases" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// sources stand in for Deezer, ListenBrainz and MusicBrainz. +type sources struct { + mu sync.Mutex + albums map[string][]musicgraph.DeezerAlbum + fresh []musicgraph.FreshRelease + groups map[string][]musicbrainz.ReleaseGroup + tracks map[int64][]musicgraph.DeezerTrack + // fail fails a source: "deezer", "listenbrainz" or "musicbrainz". + fail map[string]bool + asked []string +} + +func (s *sources) note(what string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.asked = append(s.asked, what) + for src, f := range s.fail { + if f && len(what) >= len(src) && what[:len(src)] == src { + return fmt.Errorf("%s is down: %w", src, provider.ErrUnavailable) + } + } + return nil +} + +func (s *sources) ArtistAlbums(_ context.Context, name string) ([]musicgraph.DeezerAlbum, error) { + if err := s.note("deezer albums " + name); err != nil { + return nil, err + } + al, ok := s.albums[match.Simplify(name)] + if !ok { + return nil, provider.ErrNotFound + } + return al, nil +} + +func (s *sources) FindAlbum(_ context.Context, artist, title string) (musicgraph.DeezerAlbum, error) { + if err := s.note("deezer find " + title); err != nil { + return musicgraph.DeezerAlbum{}, err + } + for _, al := range s.albums[match.Simplify(artist)] { + if al.Title == title { + return al, nil + } + } + return musicgraph.DeezerAlbum{}, provider.ErrNotFound +} + +func (s *sources) AlbumTracks(_ context.Context, id int64) ([]musicgraph.DeezerTrack, error) { + if err := s.note(fmt.Sprint("deezer tracks ", id)); err != nil { + return nil, err + } + return s.tracks[id], nil +} + +func (s *sources) FreshReleases(context.Context, int) ([]musicgraph.FreshRelease, error) { + if err := s.note("listenbrainz fresh"); err != nil { + return nil, err + } + return s.fresh, nil +} + +func (s *sources) RecentReleaseGroups(_ context.Context, mbid, _, _ string) ([]musicbrainz.ReleaseGroup, error) { + if err := s.note("musicbrainz " + mbid); err != nil { + return nil, err + } + return s.groups[mbid], nil +} + +func (s *sources) count(what string) int { + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for _, a := range s.asked { + if a == what { + n++ + } + } + return n +} + +const wednesdayID = "dd000000-0000-4000-8000-000000000001" + +var wednesday = musicgraph.ArtistRef{Name: "Wednesday", MBID: wednesdayID} + +type env struct { + src *sources + svc *releases.Service + now time.Time +} + +func newEnv(t *testing.T) *env { + t.Helper() + db, err := store.Open(t.Context(), filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + e := &env{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)} + e.src = &sources{ + fail: map[string]bool{}, + albums: map[string][]musicgraph.DeezerAlbum{ + "wednesday": { + {ID: 1, Title: "Bleeds (Deluxe Edition)", Date: "2026-09-19", Type: "album", Cover: "c1"}, + {ID: 2, Title: "Elderberry Wine", Date: "2026-07-22", Type: "single", Cover: "c2"}, + {ID: 3, Title: "Rat Saw God", Date: "2023-04-07", Type: "album", Cover: "c3"}, + {ID: 6, Title: "Rat Saw God (Deluxe)", Date: "2026-09-30", Type: "album"}, // a reissue + {ID: 4, Title: "Live at the Orange Peel", Date: "2026-08-30", Type: "album"}, + {ID: 5, Title: "Hits So Far", Date: "2026-10-01", Type: "compile"}, + }, + }, + fresh: []musicgraph.FreshRelease{ + {Title: "Bleeds", Artist: "Wednesday", ArtistMBIDs: []string{wednesdayID}, MBID: "rg-bleeds", CoverRelease: "r-bleeds", Date: "2026-09-19", PrimaryType: "Album"}, + {Title: "Split", Artist: "Wednesday & MJ Lenderman", ArtistMBIDs: []string{wednesdayID, "mj"}, MBID: "rg-split", Date: "2026-09-01", PrimaryType: "EP"}, + {Title: "Someone Else's", Artist: "Waxahatchee", ArtistMBIDs: []string{"wax"}, Date: "2026-09-01", PrimaryType: "Album"}, + }, + groups: map[string][]musicbrainz.ReleaseGroup{ + wednesdayID: { + {MBID: "rg-bleeds", Title: "Bleeds", PrimaryType: "Album", FirstReleased: "2026-09"}, + {MBID: "rg-year", Title: "A Year Alone", PrimaryType: "Album", FirstReleased: "2026"}, + }, + }, + tracks: map[int64][]musicgraph.DeezerTrack{1: {{ID: 11, Title: "Reality TV Argument Bleeds", HasPreview: true}}}, + } + e.svc = releases.New(db, releases.Options{ + Deezer: e.src, ListenBrainz: e.src, MusicBrainz: e.src, + Now: func() time.Time { return e.now }, + }) + return e +} + +func titles(rs []releases.Release) []string { + var out []string + for _, r := range rs { + out = append(out, r.Title+"|"+r.Date+"|"+r.Kind) + } + return out +} + +func TestReleasesMergeTheSources(t *testing.T) { + e := newEnv(t) + rs, err := e.svc.Get(t.Context(), wednesday) + if err != nil { + t.Fatal(err) + } + // Newest first; one Bleeds from three sources, at its precise date; + // nothing from before the lookback, or dated only by year, or + // reissued; the live album and the compilation told apart. + want := []string{ + "Hits So Far|2026-10-01|compilation", + "Bleeds|2026-09-19|album", + "Split|2026-09-01|ep", + "Live at the Orange Peel|2026-08-30|live", + "Elderberry Wine|2026-07-22|single", + } + if got := titles(rs); !slices.Equal(got, want) { + t.Fatalf("releases:\n got %q\nwant %q", got, want) + } + b := rs[1] + if b.MBID != "rg-bleeds" || b.CoverRelease != "r-bleeds" || b.DeezerID != 1 || b.Cover != "c1" || b.Artist != "Wednesday" { + t.Errorf("Bleeds: %+v", b) + } + if !slices.Equal(b.Sources, []string{"listenbrainz", "musicbrainz", "deezer"}) { + t.Errorf("Bleeds' sources: %v", b.Sources) + } + if rs[2].Artist != "Wednesday & MJ Lenderman" { + t.Errorf("the split is credited to %q", rs[2].Artist) + } +} + +func TestReleasesAreCached(t *testing.T) { + e := newEnv(t) + if _, ok, _ := e.svc.Cached(t.Context(), wednesday); ok { + t.Fatal("cached before asking") + } + if _, err := e.svc.Get(t.Context(), wednesday); err != nil { + t.Fatal(err) + } + if _, err := e.svc.Get(t.Context(), musicgraph.ArtistRef{Name: "Waxahatchee"}); err != nil { + t.Fatal(err) + } + rs, ok, err := e.svc.Cached(t.Context(), musicgraph.ArtistRef{Name: "wednesday"}) + if err != nil || !ok || len(rs) != 5 { + t.Fatalf("cached: %d, %v, %v", len(rs), ok, err) + } + if n := e.src.count("deezer albums Wednesday"); n != 1 { + t.Errorf("asked Deezer %d times", n) + } + // ListenBrainz's list is read once for every artist. + if n := e.src.count("listenbrainz fresh"); n != 1 { + t.Errorf("read ListenBrainz's list %d times", n) + } + // A day later, they're asked again. + e.now = e.now.Add(25 * time.Hour) + if _, ok, _ := e.svc.Cached(t.Context(), wednesday); ok { + t.Error("still cached a day later") + } + if _, err := e.svc.Get(t.Context(), wednesday); err != nil { + t.Fatal(err) + } + if n := e.src.count("listenbrainz fresh"); n != 2 { + t.Errorf("read ListenBrainz's list %d times", n) + } +} + +func TestReleasesWhenSourcesFail(t *testing.T) { + e := newEnv(t) + e.src.fail["musicbrainz"] = true + rs, err := e.svc.Get(t.Context(), wednesday) + if err != nil { + t.Fatal(err) + } + if len(rs) != 5 { + t.Errorf("the others' releases: %q", titles(rs)) + } + // An answer some source failed to give is kept an hour. + e.now = e.now.Add(2 * time.Hour) + if _, ok, _ := e.svc.Cached(t.Context(), wednesday); ok { + t.Error("a partial answer kept over an hour") + } + // One source down and the others with nothing new is nothing new. + if rs, err := e.svc.Get(t.Context(), musicgraph.ArtistRef{Name: "Duster", MBID: "duster"}); err != nil || len(rs) != 0 { + t.Errorf("nothing new: %v, %v", rs, err) + } + // With every source down, nothing is kept. (ListenBrainz's list is + // read again every few hours; till then, it answers.) + e.src.fail["deezer"], e.src.fail["listenbrainz"] = true, true + e.now = e.now.Add(7 * time.Hour) + if _, err := e.svc.Get(t.Context(), musicgraph.ArtistRef{Name: "Hum", MBID: "hum"}); !errors.Is(err, provider.ErrUnavailable) { + t.Errorf("every source down: %v", err) + } + if _, ok, _ := e.svc.Cached(t.Context(), musicgraph.ArtistRef{Name: "Hum"}); ok { + t.Error("kept a failure") + } +} + +func TestReleaseTracks(t *testing.T) { + e := newEnv(t) + al, tracks, err := e.svc.Tracks(t.Context(), "Wednesday", "Bleeds", 1) + if err != nil { + t.Fatal(err) + } + if al.ID != 1 || len(tracks) != 1 || tracks[0].ID != 11 { + t.Errorf("by Deezer ID: %+v, %+v", al, tracks) + } + // Without an ID, it's found by title; and remembered. + for range 2 { + al, _, err = e.svc.Tracks(t.Context(), "Wednesday", "Elderberry Wine", 0) + if err != nil || al.ID != 2 || al.Cover != "c2" { + t.Errorf("by title: %+v, %v", al, err) + } + } + if n := e.src.count("deezer find Elderberry Wine"); n != 1 { + t.Errorf("looked for it %d times", n) + } + if _, _, err := e.svc.Tracks(t.Context(), "Wednesday", "Twin Plagues", 0); !errors.Is(err, provider.ErrNotFound) { + t.Errorf("not on Deezer: %v", err) + } +} + +func TestWarmingReleases(t *testing.T) { + e := newEnv(t) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { e.svc.Run(ctx); close(done) }() + e.svc.Warm(wednesday, wednesday, musicgraph.ArtistRef{Name: "Waxahatchee"}) + deadline := time.Now().Add(5 * time.Second) + for { + _, a, _ := e.svc.Cached(t.Context(), wednesday) + _, b, _ := e.svc.Cached(t.Context(), musicgraph.ArtistRef{Name: "Waxahatchee"}) + if a && b { + break + } + if time.Now().After(deadline) { + t.Fatal("not warmed") + } + time.Sleep(10 * time.Millisecond) + } + cancel() + <-done + if n := e.src.count("deezer albums Wednesday"); n != 1 { + t.Errorf("warmed Wednesday %d times", n) + } +} diff --git a/server/internal/store/discovery.sql.gen.go b/server/internal/store/discovery.sql.gen.go index f1a3413..de81d40 100644 --- a/server/internal/store/discovery.sql.gen.go +++ b/server/internal/store/discovery.sql.gen.go @@ -104,7 +104,7 @@ const createDiscoverySet = `-- name: CreateDiscoverySet :one INSERT INTO discovery_sets (id, user_id, name, explore, mode, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?6, ?6) -RETURNING id, user_id, name, explore, mode, created_at, updated_at +RETURNING id, user_id, name, explore, mode, created_at, updated_at, fresh_days ` type CreateDiscoverySetParams struct { @@ -136,6 +136,7 @@ func (q *Queries) CreateDiscoverySet(ctx context.Context, arg CreateDiscoverySet &i.Mode, &i.CreatedAt, &i.UpdatedAt, + &i.FreshDays, ) return i, err } @@ -189,7 +190,7 @@ func (q *Queries) GetDeezerSong(ctx context.Context, songKey string) (DeezerSong } const getDiscoverySet = `-- name: GetDiscoverySet :one -SELECT id, user_id, name, explore, mode, created_at, updated_at FROM discovery_sets WHERE id = ? AND user_id = ? +SELECT id, user_id, name, explore, mode, created_at, updated_at, fresh_days FROM discovery_sets WHERE id = ? AND user_id = ? ` type GetDiscoverySetParams struct { @@ -208,6 +209,7 @@ func (q *Queries) GetDiscoverySet(ctx context.Context, arg GetDiscoverySetParams &i.Mode, &i.CreatedAt, &i.UpdatedAt, + &i.FreshDays, ) return i, err } @@ -229,6 +231,43 @@ func (q *Queries) GetLibraryIndex(ctx context.Context, linkID string) (LibraryIn return i, err } +const listAllDiscoverySetArtists = `-- name: ListAllDiscoverySetArtists :many +SELECT name, CAST(max(coalesce(mbid, '')) AS TEXT) AS mbid +FROM discovery_set_artists +GROUP BY lower(name) +ORDER BY lower(name) +` + +type ListAllDiscoverySetArtistsRow struct { + Name string + Mbid string +} + +// ListAllDiscoverySetArtists lists every user's seed artists, once each, +// to warm their recent releases. +func (q *Queries) ListAllDiscoverySetArtists(ctx context.Context) ([]ListAllDiscoverySetArtistsRow, error) { + rows, err := q.db.QueryContext(ctx, listAllDiscoverySetArtists) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListAllDiscoverySetArtistsRow{} + for rows.Next() { + var i ListAllDiscoverySetArtistsRow + if err := rows.Scan(&i.Name, &i.Mbid); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listDiscoveryBlocks = `-- name: ListDiscoveryBlocks :many SELECT user_id, artist_key, name, created_at FROM discovery_blocks WHERE user_id = ? ORDER BY name, artist_key ` @@ -335,7 +374,7 @@ func (q *Queries) ListDiscoverySetArtists(ctx context.Context, setID string) ([] } const listDiscoverySets = `-- name: ListDiscoverySets :many -SELECT discovery_sets.id, discovery_sets.user_id, discovery_sets.name, discovery_sets.explore, discovery_sets.mode, discovery_sets.created_at, discovery_sets.updated_at, +SELECT discovery_sets.id, discovery_sets.user_id, discovery_sets.name, discovery_sets.explore, discovery_sets.mode, discovery_sets.created_at, discovery_sets.updated_at, discovery_sets.fresh_days, CAST((SELECT count(*) FROM discovery_set_artists a WHERE a.set_id = discovery_sets.id) AS INTEGER) AS artists FROM discovery_sets WHERE user_id = ? @@ -350,6 +389,7 @@ type ListDiscoverySetsRow struct { Mode string CreatedAt time.Time UpdatedAt time.Time + FreshDays int64 Artists int64 } @@ -370,6 +410,7 @@ func (q *Queries) ListDiscoverySets(ctx context.Context, userID string) ([]ListD &i.Mode, &i.CreatedAt, &i.UpdatedAt, + &i.FreshDays, &i.Artists, ); err != nil { return nil, err @@ -607,13 +648,14 @@ func (q *Queries) UnblockDiscoveryArtist(ctx context.Context, arg UnblockDiscove } const updateDiscoverySet = `-- name: UpdateDiscoverySet :exec -UPDATE discovery_sets SET name = ?, explore = ?, mode = ?, updated_at = ? WHERE id = ? AND user_id = ? +UPDATE discovery_sets SET name = ?, explore = ?, mode = ?, fresh_days = ?, updated_at = ? WHERE id = ? AND user_id = ? ` type UpdateDiscoverySetParams struct { Name string Explore int64 Mode string + FreshDays int64 UpdatedAt time.Time ID string UserID string @@ -624,6 +666,7 @@ func (q *Queries) UpdateDiscoverySet(ctx context.Context, arg UpdateDiscoverySet arg.Name, arg.Explore, arg.Mode, + arg.FreshDays, arg.UpdatedAt, arg.ID, arg.UserID, diff --git a/server/internal/store/migrations/00030_fresh_releases.sql b/server/internal/store/migrations/00030_fresh_releases.sql new file mode 100644 index 0000000..caba6df --- /dev/null +++ b/server/internal/store/migrations/00030_fresh_releases.sql @@ -0,0 +1,22 @@ +-- Fresh releases (MAD-822, 823, ADR 0017): recent albums, EPs and singles +-- by a set's seeds and their neighbors. + +-- +goose Up +-- How far back a set's Fresh feed looks, in days. +ALTER TABLE discovery_sets ADD COLUMN fresh_days INTEGER NOT NULL DEFAULT 60 CHECK (fresh_days IN (30, 60, 90)); + +-- An artist's recent releases, from ListenBrainz, MusicBrainz and Deezer, +-- merged; JSON. Kept a day. +CREATE TABLE artist_releases ( + -- The artist's simplified name (match.Simplify). + key TEXT PRIMARY KEY, + name TEXT NOT NULL, + releases TEXT NOT NULL, + fetched_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL +); +CREATE INDEX artist_releases_expires ON artist_releases (expires_at); + +-- +goose Down +DROP TABLE artist_releases; +ALTER TABLE discovery_sets DROP COLUMN fresh_days; diff --git a/server/internal/store/models.gen.go b/server/internal/store/models.gen.go index 84cd7bd..541bfe7 100644 --- a/server/internal/store/models.gen.go +++ b/server/internal/store/models.gen.go @@ -9,6 +9,14 @@ import ( "time" ) +type ArtistRelease struct { + Key string + Name string + Releases string + FetchedAt time.Time + ExpiresAt time.Time +} + type Avatar struct { UserID string Data []byte @@ -78,6 +86,7 @@ type DiscoverySet struct { Mode string CreatedAt time.Time UpdatedAt time.Time + FreshDays int64 } type DiscoverySetArtist struct { diff --git a/server/internal/store/queries/discovery.sql b/server/internal/store/queries/discovery.sql index 827dc91..6701dd3 100644 --- a/server/internal/store/queries/discovery.sql +++ b/server/internal/store/queries/discovery.sql @@ -17,7 +17,7 @@ WHERE user_id = ? ORDER BY updated_at DESC, id DESC; -- name: UpdateDiscoverySet :exec -UPDATE discovery_sets SET name = ?, explore = ?, mode = ?, updated_at = ? WHERE id = ? AND user_id = ?; +UPDATE discovery_sets SET name = ?, explore = ?, mode = ?, fresh_days = ?, updated_at = ? WHERE id = ? AND user_id = ?; -- name: TouchDiscoverySet :exec UPDATE discovery_sets SET updated_at = ? WHERE id = ?; @@ -33,6 +33,14 @@ ON CONFLICT (set_id, artist_key) DO NOTHING; -- name: ListDiscoverySetArtists :many SELECT * FROM discovery_set_artists WHERE set_id = ? ORDER BY added_at, artist_key; +-- ListAllDiscoverySetArtists lists every user's seed artists, once each, +-- to warm their recent releases. +-- name: ListAllDiscoverySetArtists :many +SELECT name, CAST(max(coalesce(mbid, '')) AS TEXT) AS mbid +FROM discovery_set_artists +GROUP BY lower(name) +ORDER BY lower(name); + -- name: RemoveDiscoverySetArtist :execrows DELETE FROM discovery_set_artists WHERE set_id = ? AND artist_key = ?; diff --git a/server/internal/store/queries/releases.sql b/server/internal/store/queries/releases.sql new file mode 100644 index 0000000..72be82e --- /dev/null +++ b/server/internal/store/queries/releases.sql @@ -0,0 +1,14 @@ +-- Artists' recent releases (MAD-822). + +-- name: GetArtistReleases :one +SELECT * FROM artist_releases WHERE key = ? AND expires_at > sqlc.arg(now); + +-- name: PutArtistReleases :exec +INSERT INTO artist_releases (key, name, releases, fetched_at, expires_at) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT (key) DO UPDATE SET + name = excluded.name, releases = excluded.releases, + fetched_at = excluded.fetched_at, expires_at = excluded.expires_at; + +-- name: DeleteExpiredArtistReleases :exec +DELETE FROM artist_releases WHERE expires_at <= ?; diff --git a/server/internal/store/releases.sql.gen.go b/server/internal/store/releases.sql.gen.go new file mode 100644 index 0000000..a100a2d --- /dev/null +++ b/server/internal/store/releases.sql.gen.go @@ -0,0 +1,71 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: releases.sql + +package store + +import ( + "context" + "time" +) + +const deleteExpiredArtistReleases = `-- name: DeleteExpiredArtistReleases :exec +DELETE FROM artist_releases WHERE expires_at <= ? +` + +func (q *Queries) DeleteExpiredArtistReleases(ctx context.Context, expiresAt time.Time) error { + _, err := q.db.ExecContext(ctx, deleteExpiredArtistReleases, expiresAt) + return err +} + +const getArtistReleases = `-- name: GetArtistReleases :one + +SELECT "key", name, releases, fetched_at, expires_at FROM artist_releases WHERE key = ? AND expires_at > ?2 +` + +type GetArtistReleasesParams struct { + Key string + Now time.Time +} + +// Artists' recent releases (MAD-822). +func (q *Queries) GetArtistReleases(ctx context.Context, arg GetArtistReleasesParams) (ArtistRelease, error) { + row := q.db.QueryRowContext(ctx, getArtistReleases, arg.Key, arg.Now) + var i ArtistRelease + err := row.Scan( + &i.Key, + &i.Name, + &i.Releases, + &i.FetchedAt, + &i.ExpiresAt, + ) + return i, err +} + +const putArtistReleases = `-- name: PutArtistReleases :exec +INSERT INTO artist_releases (key, name, releases, fetched_at, expires_at) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT (key) DO UPDATE SET + name = excluded.name, releases = excluded.releases, + fetched_at = excluded.fetched_at, expires_at = excluded.expires_at +` + +type PutArtistReleasesParams struct { + Key string + Name string + Releases string + FetchedAt time.Time + ExpiresAt time.Time +} + +func (q *Queries) PutArtistReleases(ctx context.Context, arg PutArtistReleasesParams) error { + _, err := q.db.ExecContext(ctx, putArtistReleases, + arg.Key, + arg.Name, + arg.Releases, + arg.FetchedAt, + arg.ExpiresAt, + ) + return err +} From c291f9c924a3fc25afc5b0cec7bd7422c21d6e28 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Tue, 13 Oct 2026 12:40:41 -0500 Subject: [PATCH 2/2] Personal discovery: the Fresh feed (MAD-823) The Fresh mode on a set lists recent releases near your artists. Each release card shows its cover, kind, date and why it's there. Open a card to see its songs with previews; it also has a YouTube search for the whole release. A window toggle picks 30, 60 or 90 days. In Both mode, Fresh cards are mixed into the deep-cuts feed with a New badge. The song row is shared with the map's artist sheet. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- web/src/api/schema.gen.ts | 201 +++++++++++++++++- .../components/discover/map-artist-sheet.tsx | 48 +---- .../components/discover/release-card.test.tsx | 64 ++++++ web/src/components/discover/release-card.tsx | 90 ++++++++ web/src/components/discover/song-row.tsx | 48 +++++ web/src/lib/discover.test.ts | 80 ++++++- web/src/lib/discover.ts | 95 ++++++++- .../routes/_app/_authed/discover.$setId.tsx | 174 +++++++++++---- 9 files changed, 715 insertions(+), 87 deletions(-) create mode 100644 web/src/components/discover/release-card.test.tsx create mode 100644 web/src/components/discover/release-card.tsx create mode 100644 web/src/components/discover/song-row.tsx diff --git a/README.md b/README.md index 1583056..fca5e36 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)). 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. +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. A set's **Fresh** feed is "new releases for you": albums, EPs and singles out in the last 30, 60 or 90 days by your artists and their neighbors, each one's songs a tap away with previews. Choose **Both** and the new releases show up in among the deep cuts. What works where (and what doesn't yet) is in [docs/player-mode.md](docs/player-mode.md). diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index 9713bcc..dd1c8a0 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -2331,6 +2331,79 @@ export interface paths { patch?: never; trace?: never; }; + "/me/discover/sets/{setId}/fresh": { + parameters: { + query?: never; + header?: never; + path: { + setId: components["parameters"]["DiscoverySetId"]; + }; + cookie?: never; + }; + /** + * A set's Fresh feed + * @description Releases (albums, EPs, singles) from the set's last `freshDays` by + * its seeds and their nearest neighbors, that you don't have or + * want, and not by an artist you blocked. The seeds' come first, + * then the neighbors' by how near they are; albums and EPs before + * singles, and compilations and live albums last. Releases come from + * ListenBrainz, MusicBrainz and Deezer, cached a day per artist: + * artists not known yet are looked up in the background + * (`partial`). + */ + get: operations["getDiscoveryFresh"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/discover/release-tracks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * A release's songs, with previews + * @description From Deezer: by `deezerId`, or found by `artist` and `title`. + * Empty when Deezer doesn't have it, or is off. + */ + get: operations["listDiscoveryReleaseTracks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/discover/release-art/{mbid}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description A release's MBID (`coverRelease`), or with `group`, a release group's (`mbid`). */ + mbid: string; + }; + cookie?: never; + }; + /** + * A release's cover from the Cover Art Archive, through the server + * @description For a release Deezer doesn't have. `not_found` if there's none, or MusicBrainz is off. + */ + get: operations["getDiscoveryReleaseArt"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/me/discover/feedback": { parameters: { query?: never; @@ -5581,7 +5654,7 @@ export interface components { }; /** * @description Which feeds a set shows: `deep` cuts (anything new to you), `fresh` - * releases (coming), or `both`. + * releases (recent albums, EPs and singles), or `both`. * @enum {string} */ DiscoveryMode: "deep" | "fresh" | "both"; @@ -5590,6 +5663,7 @@ export interface components { name: string; explore: number; mode: components["schemas"]["DiscoveryMode"]; + freshDays: components["schemas"]["DiscoveryFreshDays"]; artistCount: number; /** Format: date-time */ createdAt: string; @@ -5602,6 +5676,7 @@ export interface components { /** @description How far the feed strays from the seeds, and toward less-known artists. */ explore: number; mode: components["schemas"]["DiscoveryMode"]; + freshDays: components["schemas"]["DiscoveryFreshDays"]; artists: components["schemas"]["DiscoverySetArtist"][]; /** Format: date-time */ createdAt: string; @@ -5630,6 +5705,53 @@ export interface components { name?: string; explore?: number; mode?: components["schemas"]["DiscoveryMode"]; + freshDays?: components["schemas"]["DiscoveryFreshDays"]; + }; + /** + * @description How far back a set's Fresh feed looks, in days. + * @enum {integer} + */ + DiscoveryFreshDays: 30 | 60 | 90; + /** @description A recent release near a set's seeds, that you don't have. */ + DiscoveryRelease: { + key: string; + title: string; + /** @description Who it's credited to. */ + artist: string; + /** @description When it first came out, YYYY-MM-DD (or YYYY-MM, if that's all that's known). */ + date: string; + /** @enum {string} */ + kind: "album" | "ep" | "single" | "compilation" | "live"; + /** @description Its MusicBrainz release group. */ + mbid?: string; + /** @description A release of it with a cover in the Cover Art Archive, for getDiscoveryReleaseArt. */ + coverRelease?: string; + /** + * Format: int64 + * @description The release on Deezer, for its songs. + */ + deezerId?: number; + /** @description Its cover's image ID on Deezer, for getDiscoveryImage (kind `cover`). */ + cover?: string; + sources: string[]; + /** @description The seed artists it's near, nearest first; the seed itself for a seed's release. */ + because: string[]; + /** @description Whether it's a seed artist's. */ + seed: boolean; + }; + DiscoveryFresh: { + items: components["schemas"]["DiscoveryRelease"][]; + days: components["schemas"]["DiscoveryFreshDays"]; + /** @description Some artists' releases weren't known yet. They're being looked up; load it again in a while. */ + partial: boolean; + library: components["schemas"]["DiscoveryLibrary"]; + /** @description The server knows nothing of how music relates, so only the seeds' releases show. */ + noGraph: boolean; + /** @description The server has no source of releases (Deezer, ListenBrainz and MusicBrainz are all off). */ + noReleases: boolean; + }; + DiscoveryReleaseTracks: { + items: components["schemas"]["DiscoveryItem"][]; }; /** @description A song you don't have, near a set's seeds. */ DiscoveryItem: { @@ -8860,6 +8982,83 @@ export interface operations { default: components["responses"]["Error"]; }; }; + getDiscoveryFresh: { + parameters: { + query?: never; + header?: never; + path: { + setId: components["parameters"]["DiscoverySetId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The feed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoveryFresh"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + listDiscoveryReleaseTracks: { + parameters: { + query: { + artist: string; + title: string; + deezerId?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The songs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoveryReleaseTracks"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + getDiscoveryReleaseArt: { + parameters: { + query?: { + /** @description The MBID is a release group's. */ + group?: boolean; + /** @description Wanted width in pixels; a hint. */ + size?: number; + }; + header?: never; + path: { + /** @description A release's MBID (`coverRelease`), or with `group`, a release group's (`mbid`). */ + mbid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The image */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "image/*": string; + }; + }; + default: components["responses"]["Error"]; + }; + }; postDiscoveryFeedback: { parameters: { query?: never; diff --git a/web/src/components/discover/map-artist-sheet.tsx b/web/src/components/discover/map-artist-sheet.tsx index 01e7e0b..f1980cc 100644 --- a/web/src/components/discover/map-artist-sheet.tsx +++ b/web/src/components/discover/map-artist-sheet.tsx @@ -1,13 +1,13 @@ import { useQuery } from '@tanstack/react-query' -import { Crosshair, ExternalLink, LoaderCircle, Pause, Play, Plus, UserX } from 'lucide-react' +import { Crosshair, LoaderCircle, 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' +import { artistSongsQuery, fansText, imageUrl, type DiscoveryMapNode } from '@/lib/discover' +import { SongRow } from './song-row' /** * An artist on the map: their best-known songs you don't have, each with @@ -99,45 +99,3 @@ function Songs({ artist }: { artist: string }) { ) } - -function SongRow({ item }: { item: DiscoveryItem }) { - const p = usePreview(item.key) - const canPlay = !!item.deezerId && !p.failed - return ( -
  • - -
    -

    {item.title}

    -

    {item.album ?? (canPlay ? '' : 'No preview')}

    -
    - -
  • - ) -} diff --git a/web/src/components/discover/release-card.test.tsx b/web/src/components/discover/release-card.test.tsx new file mode 100644 index 0000000..7132064 --- /dev/null +++ b/web/src/components/discover/release-card.test.tsx @@ -0,0 +1,64 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DiscoveryRelease } from '@/lib/discover' +import { ReleaseCard } from './release-card' + +const GET = vi.hoisted(() => vi.fn()) +vi.mock('@/api/client', () => ({ api: { GET, POST: vi.fn(), DELETE: vi.fn() } })) + +const inlet: DiscoveryRelease = { + key: 'hum\u0000inlet', + title: 'Inlet', + artist: 'Hum', + date: '2026-09-20', + kind: 'album', + deezerId: 10, + sources: ['deezer'], + because: ['Failure'], + seed: false, +} + +function renderCard(onBlock = vi.fn()) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + +
      + +
    +
    , + ) + return onBlock +} + +beforeEach(() => { + GET.mockReset() + GET.mockResolvedValue({ + data: { items: [{ key: 'hum\u0000waves', title: 'Waves', artist: 'Hum', album: 'Inlet', deezerId: 101, because: [], seed: false }] }, + response: new Response(null, { status: 200 }), + }) +}) +afterEach(cleanup) + +describe('ReleaseCard', () => { + it('says what it is and why, and lists its songs when opened', async () => { + const onBlock = renderCard() + expect(screen.getByText('New')).toBeTruthy() + expect(screen.getByText(/Album · Out/)).toBeTruthy() + expect(screen.getByText(/Near Failure/)).toBeTruthy() + expect(GET).not.toHaveBeenCalled() + + const open = screen.getByRole('button', { name: 'Songs' }) + fireEvent.click(open) + expect(open.getAttribute('aria-expanded')).toBe('true') + expect(await screen.findByRole('button', { name: 'Play a preview of Waves' })).toBeTruthy() + expect(GET).toHaveBeenCalledWith( + '/me/discover/release-tracks', + expect.objectContaining({ params: { query: { artist: 'Hum', title: 'Inlet', deezerId: 10 } } }), + ) + expect(screen.getByRole('link', { name: 'Open Inlet on YouTube' }).getAttribute('href')).toContain('Hum%20Inlet%20full%20album') + + fireEvent.click(screen.getByRole('button', { name: "Hum isn't for me" })) + expect(onBlock).toHaveBeenCalled() + }) +}) diff --git a/web/src/components/discover/release-card.tsx b/web/src/components/discover/release-card.tsx new file mode 100644 index 0000000..262c050 --- /dev/null +++ b/web/src/components/discover/release-card.tsx @@ -0,0 +1,90 @@ +import { useQuery } from '@tanstack/react-query' +import { ChevronDown, ExternalLink, UserX } from 'lucide-react' +import { motion } from 'motion/react' +import { useId, useState } from 'react' +import { errorMessage } from '@/api/errors' +import { Artwork } from '@/components/artwork' +import { Notice } from '@/components/notice' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { becauseText, kindText, releaseCoverUrl, releaseDateText, releaseTracksQuery, releaseYoutubeUrl, type DiscoveryRelease } from '@/lib/discover' +import { fadeUp } from '@/lib/motion' +import { cn } from '@/lib/utils' +import { SongRow } from './song-row' + +/** + * A recent release you don't have: what it is, when it came out and why + * it's here. Open it for its songs, each with a preview, or search + * YouTube for the whole thing. + */ +export function ReleaseCard({ release, isNew, onBlock }: { release: DiscoveryRelease; isNew?: boolean; onBlock: () => void }) { + const [open, setOpen] = useState(false) + const songsId = useId() + const why = release.seed ? '' : becauseText(release) + return ( + +
    +
    + +
    +

    + {isNew && New} + {release.title} +

    +

    {release.artist}

    +

    + + {kindText(release.kind)} · {releaseDateText(release.date)} + + {why && · {why}} +

    +
    +
    +
    + + + +
    +
    + {open && ( +
    + +
    + )} +
    + ) +} + +function Songs({ release }: { release: DiscoveryRelease }) { + const songs = useQuery(releaseTracksQuery(release)) + if (songs.isPending) { + return ( +
    + {[0, 1, 2].map((i) => ( + + ))} +
    + ) + } + if (songs.isError) return {errorMessage(songs.error)} + if (songs.data.items.length === 0) { + return

    Deezer doesn't have its songs, so there are no previews. YouTube might.

    + } + return ( +
      + {songs.data.items.map((it) => ( + + ))} +
    + ) +} diff --git a/web/src/components/discover/song-row.tsx b/web/src/components/discover/song-row.tsx new file mode 100644 index 0000000..3c92e74 --- /dev/null +++ b/web/src/components/discover/song-row.tsx @@ -0,0 +1,48 @@ +import { ExternalLink, LoaderCircle, Pause, Play } from 'lucide-react' +import { Artwork } from '@/components/artwork' +import { Button } from '@/components/ui/button' +import { imageUrl, togglePreview, usePreview, youtubeUrl, type DiscoveryItem } from '@/lib/discover' +import { cn } from '@/lib/utils' + +/** A song in a list: its preview, and a YouTube search for the whole thing. */ +export function SongRow({ item }: { item: DiscoveryItem }) { + const p = usePreview(item.key) + const canPlay = !!item.deezerId && !p.failed + return ( +
  • + +
    +

    {item.title}

    +

    {item.album ?? (canPlay ? '' : 'No preview')}

    +
    + +
  • + ) +} diff --git a/web/src/lib/discover.test.ts b/web/src/lib/discover.test.ts index 27748a3..6268de7 100644 --- a/web/src/lib/discover.test.ts +++ b/web/src/lib/discover.test.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from 'vitest' -import { becauseText, fansText, feedItems, imageUrl, youtubeUrl, type DiscoveryFeed } from './discover' +import { + becauseText, + fansText, + feedItems, + imageUrl, + kindText, + mixFresh, + releaseCoverUrl, + releaseDateText, + releaseYoutubeUrl, + youtubeUrl, + type DiscoveryFeed, + type DiscoveryRelease, +} from './discover' const item = (key: string) => ({ key, title: key, artist: 'A', because: ['Slowdive'], seed: false }) const page = (...keys: string[]): DiscoveryFeed => ({ @@ -54,3 +67,68 @@ describe('fansText', () => { expect(fansText(undefined)).toBe('') }) }) + +const release = (key: string): DiscoveryRelease => ({ + key, + title: key, + artist: 'A', + date: '2026-10-01', + kind: 'album', + sources: ['deezer'], + because: ['Slowdive'], + seed: false, +}) + +describe('mixFresh', () => { + const songs = ['a', 'b', 'c', 'd', 'e', 'f'].map(item) + const order = (entries: ReturnType) => entries.map((e) => (e.kind === 'song' ? e.item.key : `R${e.release.key}`)) + it('puts a release after the first song, then one every few', () => { + expect(order(mixFresh(songs, ['1', '2', '3'].map(release), false, 4))).toEqual(['a', 'R1', 'b', 'c', 'd', 'e', 'R2', 'f']) + }) + it('keeps what is left over for the end of the feed', () => { + expect(order(mixFresh(songs.slice(0, 2), ['1', '2'].map(release), true))).toEqual(['a', 'R1', 'b', 'R2']) + expect(order(mixFresh([], ['1'].map(release), true))).toEqual(['R1']) + }) + it('is just the songs without releases', () => { + expect(order(mixFresh(songs.slice(0, 2), [], true))).toEqual(['a', 'b']) + }) +}) + +describe('releaseDateText', () => { + const now = new Date(2026, 9, 13, 15).getTime() + it('says how lately it came out', () => { + expect(releaseDateText('2026-10-13', now)).toBe('Out today') + expect(releaseDateText('2026-10-12', now)).toBe('Out yesterday') + expect(releaseDateText('2026-10-09', now)).toBe('Out 4 days ago') + expect(releaseDateText('2026-09-19', now)).toMatch(/^Out .*19/) + }) + it('names the month when that is all that is known', () => { + expect(releaseDateText('2026-09', now)).toMatch(/^Out in /) + expect(releaseDateText('', now)).toBe('') + }) +}) + +describe('releaseCoverUrl', () => { + it("uses Deezer's cover, else the Cover Art Archive's", () => { + expect(releaseCoverUrl({ cover: 'abc' }, 120)).toBe('/api/me/discover/images/cover/abc?size=120') + expect(releaseCoverUrl({ coverRelease: 'r1', mbid: 'g1' })).toBe('/api/me/discover/release-art/r1?size=250') + expect(releaseCoverUrl({ mbid: 'g1' })).toBe('/api/me/discover/release-art/g1?group=true&size=250') + expect(releaseCoverUrl({})).toBeUndefined() + }) +}) + +describe('releaseYoutubeUrl', () => { + it('searches for the whole album, or the single', () => { + expect(releaseYoutubeUrl({ artist: 'Hum', title: 'Inlet', kind: 'album' })).toBe( + 'https://www.youtube.com/results?search_query=Hum%20Inlet%20full%20album', + ) + expect(releaseYoutubeUrl({ artist: 'Hum', title: 'Waves', kind: 'single' })).toBe('https://www.youtube.com/results?search_query=Hum%20Waves') + }) +}) + +describe('kindText', () => { + it('names kinds of release', () => { + expect(kindText('ep')).toBe('EP') + expect(kindText('live')).toBe('Live album') + }) +}) diff --git a/web/src/lib/discover.ts b/web/src/lib/discover.ts index d628cfc..58b9a31 100644 --- a/web/src/lib/discover.ts +++ b/web/src/lib/discover.ts @@ -17,6 +17,12 @@ 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 type DiscoveryRelease = components['schemas']['DiscoveryRelease'] +export type DiscoveryFresh = components['schemas']['DiscoveryFresh'] +export type DiscoveryFreshDays = components['schemas']['DiscoveryFreshDays'] + +/** How far back a Fresh feed can look. */ +export const freshWindows: DiscoveryFreshDays[] = [30, 60, 90] export const setsQuery = queryOptions({ queryKey: ['discover', 'sets'], @@ -52,6 +58,29 @@ export async function refreshFeed(qc: QueryClient, setId: string) { qc.setQueryData(feedQuery(setId).queryKey, { pages: [first], pageParams: [''] }) } +export const freshQuery = (setId: string) => + queryOptions({ + queryKey: ['discover', 'fresh', setId], + queryFn: ({ signal }) => unwrap(api.GET('/me/discover/sets/{setId}/fresh', { params: { path: { setId } }, signal })), + staleTime: 10 * 60_000, + // Some artists' releases are being looked up: look again a few + // times, and the feed fills out. + refetchInterval: (q) => (q.state.data?.partial && q.state.dataUpdateCount < 4 ? 8_000 : false), + }) + +export const releaseTracksQuery = (r: Pick) => + queryOptions({ + queryKey: ['discover', 'release-tracks', r.artist, r.title, r.deezerId ?? 0], + queryFn: ({ signal }) => + unwrap( + api.GET('/me/discover/release-tracks', { + params: { query: { artist: r.artist, title: r.title, ...(r.deezerId ? { deezerId: r.deezerId } : {}) } }, + signal, + }), + ), + staleTime: 60 * 60_000, + }) + export const blocksQuery = queryOptions({ queryKey: ['discover', 'blocks'], queryFn: () => unwrap(api.GET('/me/discover/blocks')), @@ -98,13 +127,14 @@ export function setChanged(qc: QueryClient, set: DiscoverySet) { 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 }) + void qc.invalidateQueries({ queryKey: freshQuery(set.id).queryKey }) } export function createSet(name: string, artists: { name: string; mbid?: string }[] = []) { return unwrap(api.POST('/me/discover/sets', { body: { name, artists } })) } -export function updateSet(id: string, body: { name?: string; explore?: number; mode?: DiscoveryMode }) { +export function updateSet(id: string, body: { name?: string; explore?: number; mode?: DiscoveryMode; freshDays?: DiscoveryFreshDays }) { return unwrap(api.PATCH('/me/discover/sets/{setId}', { params: { path: { setId: id } }, body })) } @@ -173,6 +203,69 @@ export function imageUrl(kind: 'cover' | 'artist', id: string | undefined, size return `/api/me/discover/images/${kind}/${encodeURIComponent(id)}?size=${size}` } +/** A release's cover: Deezer's, else the Cover Art Archive's, through the server. */ +export function releaseCoverUrl(r: Pick, size = 250) { + if (r.cover) return imageUrl('cover', r.cover, size) + if (r.coverRelease) return `/api/me/discover/release-art/${encodeURIComponent(r.coverRelease)}?size=${size}` + if (r.mbid) return `/api/me/discover/release-art/${encodeURIComponent(r.mbid)}?group=true&size=${size}` + return undefined +} + +/** A YouTube search for a release: the whole album, or the single. */ +export function releaseYoutubeUrl(r: Pick) { + const q = `${r.artist} ${r.title}${r.kind === 'single' ? '' : ' full album'}` + return `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}` +} + +const kinds: Record = { + album: 'Album', + ep: 'EP', + single: 'Single', + compilation: 'Compilation', + live: 'Live album', +} + +/** "EP". */ +export function kindText(kind: DiscoveryRelease['kind']) { + return kinds[kind] ?? 'Release' +} + +const day = 24 * 3600_000 + +/** "Out today", "Out 3 days ago", "Out Sep 19", or "Out in September" when only the month is known. */ +export function releaseDateText(date: string, now = Date.now()) { + const [y, m, d] = date.split('-').map(Number) + if (!y || !m) return '' + if (!d) return `Out in ${new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString(undefined, { month: 'long', timeZone: 'UTC' })}` + const at = Date.UTC(y, m - 1, d) + const today = new Date(now) + const days = Math.round((Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) - at) / day) + if (days <= 0) return 'Out today' + if (days === 1) return 'Out yesterday' + if (days < 7) return `Out ${days} days ago` + return `Out ${new Date(at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })}` +} + +/** A song or a release, in a feed that mixes them. */ +export type FeedEntry = { kind: 'song'; item: DiscoveryItem } | { kind: 'release'; release: DiscoveryRelease } + +/** + * Mixes Fresh releases into a feed of songs ("Both"): the first after the + * first song, then one every `every` songs. Releases left over go at the + * end once the feed has no more pages (`done`), so they don't jump as + * pages load. + */ +export function mixFresh(songs: readonly DiscoveryItem[], releases: readonly DiscoveryRelease[], done: boolean, every = 4): FeedEntry[] { + const out: FeedEntry[] = [] + let r = 0 + songs.forEach((item, i) => { + out.push({ kind: 'song', item }) + if (r < releases.length && (i === 0 || i % every === 0)) out.push({ kind: 'release', release: releases[r++] }) + }) + if (done) for (; r < releases.length; r++) out.push({ kind: 'release', release: releases[r] }) + return out +} + /** "1.2M fans". */ export function fansText(fans: number | undefined) { if (!fans) return '' diff --git a/web/src/routes/_app/_authed/discover.$setId.tsx b/web/src/routes/_app/_authed/discover.$setId.tsx index 3cdbf80..dd22f36 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 { ReleaseCard } from '@/components/discover/release-card' import { SetTabs } from '@/components/discover/set-tabs' import { Notice } from '@/components/notice' import { Button } from '@/components/ui/button' @@ -22,6 +23,9 @@ import { deleteSet, feedItems, feedQuery, + freshQuery, + freshWindows, + mixFresh, refreshFeed, removeArtist, setChanged, @@ -32,6 +36,8 @@ import { unblock, updateSet, type DiscoveryFeed, + type DiscoveryFresh, + type DiscoveryFreshDays, type DiscoveryItem, type DiscoveryMode, type DiscoverySet, @@ -75,9 +81,11 @@ function SetPage() { {set.data.artists.length === 0 ? ( -

    Add an artist or two, and songs near them show up here.

    +

    Add an artist or two, and music near them shows up here.

    + ) : set.data.mode === 'fresh' ? ( + ) : ( - + )} ) @@ -183,12 +191,12 @@ function Seeds({ set }: { set: DiscoverySet }) { ) } -/** How far the feed strays, and which feeds it shows. */ +/** How far the feed strays, which feeds it shows, and how far back Fresh looks. */ function Tuning({ set }: { set: DiscoverySet }) { // While dragging, and until the change is saved. const [dragged, setDragged] = useState() const explore = dragged ?? set.explore - const update = useSetMutation((body: { explore?: number; mode?: DiscoveryMode }) => updateSet(set.id, body)) + const update = useSetMutation((body: { explore?: number; mode?: DiscoveryMode; freshDays?: DiscoveryFreshDays }) => updateSet(set.id, body)) return (
    ) } -function Feed({ set }: { set: DiscoverySet }) { +/** "Not for me": an artist out of every feed, with an undo. */ +function useNotForMe(setId: string, onBlock: (artist: string) => void) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (artist: string) => block(artist), + onMutate: (artist) => { + tap() + onBlock(artist) + qc.setQueryData(freshQuery(setId).queryKey, (d) => + d ? { ...d, items: d.items.filter((r) => r.artist.toLowerCase() !== artist.toLowerCase()) } : d, + ) + }, + onSuccess: (b) => { + void qc.invalidateQueries({ queryKey: blocksQuery.queryKey }) + 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: feedQuery(setId).queryKey }) + void qc.invalidateQueries({ queryKey: freshQuery(setId).queryKey }) + }), + }, + }) + }, + onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), + }) +} + +/** The set's songs, new to you; with its Fresh releases mixed in, for "Both". */ +function Feed({ set, withFresh }: { set: DiscoverySet; withFresh: boolean }) { const qc = useQueryClient() const pages = useInfiniteQuery(feedQuery(set.id)) + const fresh = useQuery({ ...freshQuery(set.id), enabled: withFresh }) const [thumbed, setThumbed] = useState>(new Set()) const items = feedItems(pages.data?.pages) const first = pages.data?.pages[0] const { hasNextPage, isFetchingNextPage, fetchNextPage } = pages + const entries = mixFresh(items, withFresh ? (fresh.data?.items ?? []) : [], !hasNextPage) // Load the next page as the end of the list scrolls into view. const end = useRef(null) @@ -267,28 +328,7 @@ function Feed({ set }: { set: DiscoverySet }) { toast({ message: kind === 'up' ? `Glad you like “${item.title}”` : `You won't see “${item.title}” again` }), onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), }) - const notForMe = useMutation({ - mutationFn: (item: DiscoveryItem) => block(item.artist), - onMutate: (item) => { - tap() - drop((it) => it.artist.toLowerCase() !== item.artist.toLowerCase()) - }, - onSuccess: (b) => { - void qc.invalidateQueries({ queryKey: blocksQuery.queryKey }) - 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: feedQuery(set.id).queryKey }) - }), - }, - }) - }, - onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), - }) + const notForMe = useNotForMe(set.id, (artist) => drop((it) => it.artist.toLowerCase() !== artist.toLowerCase())) return (
    @@ -319,15 +359,19 @@ function Feed({ set }: { set: DiscoverySet }) {

    ) : ( - {items.map((it) => ( - rate.mutate({ kind, item: it })} - onBlock={() => notForMe.mutate(it)} - /> - ))} + {entries.map((e) => + e.kind === 'release' ? ( + notForMe.mutate(e.release.artist)} /> + ) : ( + rate.mutate({ kind, item: e.item })} + onBlock={() => notForMe.mutate(e.item.artist)} + /> + ), + )} )}
    @@ -337,3 +381,57 @@ function Feed({ set }: { set: DiscoverySet }) {
    ) } + +/** The set's recent releases: "new releases for you". */ +function FreshFeed({ set }: { set: DiscoverySet }) { + const fresh = useQuery(freshQuery(set.id)) + const notForMe = useNotForMe(set.id, () => {}) + const data = fresh.data + return ( +
    +
    +
    +

    Fresh

    +

    Out in the last {set.freshDays} days

    +
    + +
    + {data?.library.building && ( +

    Still indexing your library, so a release you have might slip through.

    + )} + {data?.partial && (data.items.length > 0 || fresh.isFetching) && ( +

    + + Still looking up some artists' releases +

    + )} + {fresh.isPending ? ( +
    +

    Looking for what came out lately…

    + {[0, 1, 2].map((i) => ( + + ))} +
    + ) : fresh.isError && !data ? ( + {errorMessage(fresh.error)} + ) : !data || data.items.length === 0 ? ( +

    + {data?.noReleases + ? "This server has no source of new releases: its admin can turn on Deezer, ListenBrainz or MusicBrainz." + : data?.partial + ? "Looking up your artists' releases. They'll show up here shortly." + : `Nothing new from your artists or their neighbors in the last ${set.freshDays} days. Look further back, or add another artist.`} +

    + ) : ( + + {data.items.map((r) => ( + notForMe.mutate(r.artist)} /> + ))} + + )} +
    + ) +}