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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Images are published to `ghcr.io/madeofpendletonwool/syncphony`: `:main` tracks

## Finding music you don't have

The **Discover** tab is yours alone, outside any room. Make a set of artists you love ("Moody post-rock", "90s alt"), and it fills with songs near them that aren't on your own Navidrome, each saying which of your artists it's near. Play a 30-second preview, open the whole song on YouTube, give it a thumb, or say an artist isn't for you. Your library is only used to skip what you have, never to guess your taste ([ADR 0017](docs/adr/0017-personal-discovery.md)). 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).

Expand Down
196 changes: 193 additions & 3 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -8337,6 +8440,8 @@ components:
maximum: 100
mode:
$ref: "#/components/schemas/DiscoveryMode"
freshDays:
$ref: "#/components/schemas/DiscoveryFreshDays"
artistCount:
type: integer
createdAt:
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion docs/adr/0017-personal-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

- **Status:** accepted
- **Date:** 2026-10-13
- **Issues:** MAD-814 (Phase 12), stage 1: MAD-815..819, 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

Expand Down Expand Up @@ -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.
Expand Down
39 changes: 32 additions & 7 deletions server/cmd/syncphony/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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})
Expand All @@ -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
Expand Down
Loading
Loading