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 (
-
- togglePreview(item)}
- aria-label={!canPlay ? `No preview of ${item.title}` : p.playing ? `Pause ${item.title}` : `Play a preview of ${item.title}`}
- className="group relative shrink-0 rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-default"
- >
-
- {canPlay && (
-
- {p.loading ? : p.playing ? : }
-
- )}
- {(p.playing || p.progress > 0) && (
-
-
-
- )}
-
-
-
{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 (
+
+ togglePreview(item)}
+ aria-label={!canPlay ? `No preview of ${item.title}` : p.playing ? `Pause ${item.title}` : `Play a preview of ${item.title}`}
+ className="group relative shrink-0 rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-default"
+ >
+
+ {canPlay && (
+
+ {p.loading ? : p.playing ? : }
+
+ )}
+ {(p.playing || p.progress > 0) && (
+
+
+
+ )}
+
+
+
{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 (
@@ -212,23 +220,76 @@ function Tuning({ set }: { set: DiscoverySet }) {
onValueChange={(v) => v && update.mutate({ mode: v as DiscoveryMode })}
aria-label="Which feeds"
>
- Deep cuts
-
+
+ Deep cuts
+
+
Fresh
Both
+ {set.mode !== 'deep' && (
+
+ Fresh looks back
+ v && update.mutate({ freshDays: Number(v) as DiscoveryFreshDays })}
+ aria-label="How far back Fresh looks"
+ >
+ {freshWindows.map((d) => (
+
+ {d} days
+
+ ))}
+
+
+ )}
)
}
-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
+
+
void fresh.refetch()}>
+
+ Refresh
+
+
+ {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)} />
+ ))}
+
+ )}
+
+ )
+}