From afb4cd3bbe92ffb408a24763d61865622e1a202b Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 7 Oct 2026 15:30:37 -0500 Subject: [PATCH 1/3] Artist and album notes, album kinds, genre artists (MAD-764, MAD-765) The knowledge behind richer artist and album pages. - musicbrainz: Profile reads an artist with their members (someone who left and came back listed once), curated genres and release groups. FindAlbum finds an album's release group by artist and title, ignoring edition notes, with its first release, labels (first edition's first), genres and Wikipedia links. Kind names a release: album, single, ep, compilation or live. - linernotes: Artist and Album write page notes (bio from Wikipedia, "Formed in Leeds in 1994", "Split up in 2012", members with years and roles, genres, release kinds; an album's type, labels and summary), cached in page_notes_cache like songs' notes. - provider.Album gains Kind, from Navidrome's OpenSubsonic release types and Spotify's album_type. - musicgraph: Genre returns a tag's top artists from Last.fm's tag.getTopArtists (a new TagSource), cached in musicgraph_tags, or else the cached artists carrying the tag, kept briefly. Co-Authored-By: Claude Opus 5.5 --- server/internal/linernotes/linernotes.go | 3 +- server/internal/linernotes/linernotes_test.go | 38 ++ server/internal/linernotes/pages.go | 298 ++++++++++++++++ server/internal/musicbrainz/details.go | 22 +- server/internal/musicbrainz/profile.go | 336 ++++++++++++++++++ server/internal/musicbrainz/profile_test.go | 120 +++++++ .../musicbrainz/testdata/artist-profile.json | 17 + .../testdata/release-group-search.json | 3 + .../musicbrainz/testdata/release-group.json | 3 + .../musicbrainz/testdata/release-groups.json | 5 + .../musicbrainz/testdata/releases-labels.json | 5 + server/internal/musicgraph/genre.go | 168 +++++++++ server/internal/musicgraph/genre_test.go | 73 ++++ server/internal/musicgraph/musicgraph.go | 3 + server/internal/musicgraph/musicgraph_test.go | 2 +- server/internal/provider/navidrome/client.go | 4 + .../provider/navidrome/navidrome_test.go | 25 ++ server/internal/provider/navidrome/session.go | 27 ++ server/internal/provider/spotify/client.go | 2 + server/internal/provider/spotify/session.go | 14 + server/internal/provider/types.go | 13 + server/internal/store/liner_notes.sql.gen.go | 61 ++++ .../store/migrations/00022_artist_pages.sql | 33 ++ server/internal/store/models.gen.go | 17 + server/internal/store/musicgraph.sql.gen.go | 104 ++++++ server/internal/store/queries/liner_notes.sql | 12 + server/internal/store/queries/musicgraph.sql | 24 ++ 27 files changed, 1414 insertions(+), 18 deletions(-) create mode 100644 server/internal/linernotes/pages.go create mode 100644 server/internal/musicbrainz/profile.go create mode 100644 server/internal/musicbrainz/profile_test.go create mode 100644 server/internal/musicbrainz/testdata/artist-profile.json create mode 100644 server/internal/musicbrainz/testdata/release-group-search.json create mode 100644 server/internal/musicbrainz/testdata/release-group.json create mode 100644 server/internal/musicbrainz/testdata/release-groups.json create mode 100644 server/internal/musicbrainz/testdata/releases-labels.json create mode 100644 server/internal/musicgraph/genre.go create mode 100644 server/internal/musicgraph/genre_test.go create mode 100644 server/internal/store/migrations/00022_artist_pages.sql diff --git a/server/internal/linernotes/linernotes.go b/server/internal/linernotes/linernotes.go index dcba1c3..e4b071d 100644 --- a/server/internal/linernotes/linernotes.go +++ b/server/internal/linernotes/linernotes.go @@ -233,7 +233,8 @@ func (s *Service) put(ctx context.Context, ref provider.TrackRef, n *Notes, sure // Sweep deletes expired cache entries. func (s *Service) Sweep(ctx context.Context) error { - return s.db.DeleteExpiredLinerNotes(ctx, s.opts.Now()) + now := s.opts.Now() + return errors.Join(s.db.DeleteExpiredLinerNotes(ctx, now), s.db.DeleteExpiredPageNotes(ctx, now)) } // --- Writing the notes ------------------------------------------------------- diff --git a/server/internal/linernotes/linernotes_test.go b/server/internal/linernotes/linernotes_test.go index 439ec2c..131b4bb 100644 --- a/server/internal/linernotes/linernotes_test.go +++ b/server/internal/linernotes/linernotes_test.go @@ -84,3 +84,41 @@ func TestSiteKey(t *testing.T) { } } } + +func TestWriteArtist(t *testing.T) { + p := musicbrainz.Profile{ + Artist: musicbrainz.Artist{MBID: "a", Name: "Band", Type: "Group", Area: "UK", BeginArea: "Leeds", Begin: "1994-05", End: "2012", Ended: true}, + Members: []musicbrainz.Member{ + {Name: "Now", Begin: "1996"}, + {Name: "Then", Begin: "1994", End: "1999-02", Ended: true, Roles: []string{"guitar"}}, + }, + Genres: []string{"art rock"}, + ReleaseGroups: []musicbrainz.ReleaseGroup{{Title: "Hits", PrimaryType: "Album", SecondaryTypes: []string{"Compilation"}, FirstReleased: "2010-01"}}, + } + n := writeArtist(p) + if n.About != "Group from UK" || len(n.Genres) != 1 { + t.Errorf("notes: %+v", n) + } + var facts []string + for _, f := range n.Facts { + facts = append(facts, f.Kind+"="+f.Text) + } + if got, want := strings.Join(facts, "; "), "began=Formed in Leeds in 1994; ended=Split up in 2012"; got != want { + t.Errorf("facts = %q, want %q", got, want) + } + if m := n.Members; len(m) != 2 || !m[0].Current || m[0].From != 1996 || m[1].Current || m[1].To != 1999 || m[0].Roles == nil { + t.Errorf("members: %+v", m) + } + if r := n.Releases; len(r) != 1 || r[0].Kind != "compilation" || r[0].Year != 2010 { + t.Errorf("releases: %+v", r) + } + + person := musicbrainz.Profile{Artist: musicbrainz.Artist{Name: "Solo", Type: "Person", Begin: "1950", End: "2016", Ended: true}} + n = writeArtist(person) + if len(n.Facts) != 2 || n.Facts[0].Text != "Born in 1950" || n.Facts[1].Text != "Died in 2016" { + t.Errorf("person facts: %+v", n.Facts) + } + if n := writeArtist(musicbrainz.Profile{Artist: musicbrainz.Artist{Name: "Who"}}); len(n.Facts) != 0 || n.Members == nil { + t.Errorf("unknown artist: %+v", n) + } +} diff --git a/server/internal/linernotes/pages.go b/server/internal/linernotes/pages.go new file mode 100644 index 0000000..0ef94ae --- /dev/null +++ b/server/internal/linernotes/pages.go @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package linernotes + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/match" + "github.com/madeofpendletonwool/syncphony/server/internal/musicbrainz" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// Notes for artist and album pages: who an artist is, from MusicBrainz +// and Wikipedia, and where an album came out. Like songs' notes, they're +// cached, misses included. + +// ArtistNotes are the notes on an artist's page. +type ArtistNotes struct { + MBID string `json:"mbid"` + Name string `json:"name"` + // About is a one-liner: "Group from Seattle". + About string `json:"about,omitempty"` + // Bio is a short summary from Wikipedia, and BioURL the article. + Bio string `json:"bio,omitempty"` + BioURL string `json:"bioUrl,omitempty"` + // Facts are where and when they began, and ended. + Facts []Fact `json:"facts"` + Members []Member `json:"members"` + Genres []string `json:"genres"` + // Releases are their albums, singles and EPs, oldest first. + Releases []ReleaseKind `json:"releases"` +} + +// Fact kinds on an artist's page. +const ( + FactBegan = "began" + FactEnded = "ended" +) + +// Member is someone in a group. +type Member struct { + Name string `json:"name"` + // From and To are the years they were in it; 0 if unknown. + From int `json:"from,omitempty"` + To int `json:"to,omitempty"` + // Current is whether they still are. + Current bool `json:"current"` + Roles []string `json:"roles"` +} + +// ReleaseKind is one of an artist's releases and what kind it is. +type ReleaseKind struct { + Title string `json:"title"` + // Kind is "album", "single", "ep", "compilation" or "live". + Kind string `json:"kind"` + Year int `json:"year,omitempty"` +} + +// AlbumNotes are the notes on an album's page. +type AlbumNotes struct { + MBID string `json:"mbid"` + Title string `json:"title"` + // Kind is "album", "single", "ep", "compilation" or "live". + Kind string `json:"kind"` + // FirstReleased is YYYY, YYYY-MM or YYYY-MM-DD, or empty. + FirstReleased string `json:"firstReleased,omitempty"` + Labels []string `json:"labels"` + Genres []string `json:"genres"` + // About is a short summary from Wikipedia, and AboutURL the article. + About string `json:"about,omitempty"` + AboutURL string `json:"aboutUrl,omitempty"` +} + +// Page note kinds, as cached. +const ( + pageArtist = "artist" + pageAlbum = "album" +) + +// Artist returns the notes for the artist called name, or +// provider.ErrNotFound if MusicBrainz doesn't know them. +func (s *Service) Artist(ctx context.Context, name string) (ArtistNotes, error) { + var n ArtistNotes + err := s.page(ctx, pageArtist, match.Simplify(name), &n, func(ctx context.Context) (any, bool, error) { + return s.writeArtist(ctx, name) + }) + return n, err +} + +// Album returns the notes for an album, or provider.ErrNotFound if +// MusicBrainz doesn't know it. +func (s *Service) Album(ctx context.Context, artist, title string) (AlbumNotes, error) { + var n AlbumNotes + t, _ := match.Title(title) + err := s.page(ctx, pageAlbum, match.Simplify(artist)+"\x00"+t, &n, func(ctx context.Context) (any, bool, error) { + return s.writeAlbum(ctx, artist, title) + }) + return n, err +} + +// page reads cached notes into v, or writes them with write and caches +// them. write returns nil notes for a miss, and sure false when part of +// them couldn't be found for now, so they're kept briefly. +func (s *Service) page(ctx context.Context, kind, key string, v any, write func(context.Context) (any, bool, error)) error { + if strings.Trim(key, "\x00") == "" { + return fmt.Errorf("%s notes with no name: %w", kind, provider.ErrNotFound) + } + got, err, _ := s.group.Do(kind+"\x00"+key, func() (any, error) { + now := s.opts.Now() + row, err := s.db.GetCachedPageNotes(ctx, store.GetCachedPageNotesParams{Kind: kind, Key: key, Now: now}) + switch { + case err == nil: + if !row.Found { + return nil, nil + } + return []byte(row.Notes), nil + case !store.IsNotFound(err): + return nil, err + } + // Shared by everyone waiting, so not cut short by the first to ask. + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), fetchTimeout) + defer cancel() + n, sure, err := write(ctx) + if err != nil { + return nil, err + } + s.putPage(ctx, kind, key, n, sure, now) + if n == nil { + return nil, nil + } + return json.Marshal(n) + }) + if err != nil { + return err + } + data, _ := got.([]byte) + if data == nil { + return fmt.Errorf("%s notes for %q: %w", kind, key, provider.ErrNotFound) + } + return json.Unmarshal(data, v) +} + +func (s *Service) putPage(ctx context.Context, kind, key string, n any, sure bool, now time.Time) { + data := []byte("{}") + if n != nil { + var err error + if data, err = json.Marshal(n); err != nil { + slog.Warn("page notes: encoding", "err", err) + return + } + } + ttl := s.opts.MissTTL + if n != nil && sure { + ttl = s.opts.FoundTTL + } + if err := s.db.PutCachedPageNotes(ctx, store.PutCachedPageNotesParams{ + Kind: kind, Key: key, Found: n != nil, Notes: string(data), FetchedAt: now, ExpiresAt: now.Add(ttl), + }); err != nil { + slog.Warn("caching page notes", "kind", kind, "err", err) + } +} + +func (s *Service) writeArtist(ctx context.Context, name string) (any, bool, error) { + mbid, err := s.mb.FindArtist(ctx, name) + if errors.Is(err, provider.ErrNotFound) { + return nil, true, nil + } + if err != nil { + return nil, false, err + } + p, err := s.mb.Profile(ctx, mbid) + if errors.Is(err, provider.ErrNotFound) { + return nil, true, nil + } + if err != nil { + return nil, false, err + } + n := writeArtist(p) + sure := s.bio(ctx, p.WikidataID, p.WikipediaURL, &n.Bio, &n.BioURL) + return &n, sure, nil +} + +func (s *Service) writeAlbum(ctx context.Context, artist, title string) (any, bool, error) { + a, err := s.mb.FindAlbum(ctx, artist, title) + if errors.Is(err, provider.ErrNotFound) { + return nil, true, nil + } + if err != nil { + return nil, false, err + } + n := AlbumNotes{ + MBID: a.MBID, Title: a.Title, Kind: a.Kind(), FirstReleased: a.FirstReleased, + Labels: nonNil(a.Labels), Genres: nonNil(a.Genres), + } + sure := s.bio(ctx, a.WikidataID, a.WikipediaURL, &n.About, &n.AboutURL) + return &n, sure, nil +} + +// bio reads a Wikipedia summary into text and url, if there's one. It +// returns false if Wikipedia couldn't be asked, so it's asked again soon. +func (s *Service) bio(ctx context.Context, wikidataID, wikipediaURL string, text, url *string) bool { + if s.wiki == nil { + return true + } + sum, err := s.wiki.artistSummary(ctx, wikidataID, wikipediaURL) + switch { + case err == nil: + *text, *url = sum.Text, sum.URL + case errors.Is(err, errNoArticle): + default: + slog.Debug("page notes: wikipedia", "wikidata", wikidataID, "err", err) + return false + } + return true +} + +// writeArtist turns a MusicBrainz profile into notes. +func writeArtist(p musicbrainz.Profile) ArtistNotes { + n := ArtistNotes{ + MBID: p.MBID, Name: p.Name, About: about(&p.Artist), + Facts: []Fact{}, Members: []Member{}, Genres: nonNil(p.Genres), Releases: []ReleaseKind{}, + } + if f := began(p.Artist); f != "" { + n.Facts = append(n.Facts, Fact{Kind: FactBegan, Text: f}) + } + if f := ended(p.Artist); f != "" { + n.Facts = append(n.Facts, Fact{Kind: FactEnded, Text: f}) + } + for _, m := range p.Members { + n.Members = append(n.Members, Member{ + Name: m.Name, From: year(m.Begin), To: year(m.End), Current: !m.Ended && m.End == "", Roles: nonNil(m.Roles), + }) + } + for _, rg := range p.ReleaseGroups { + n.Releases = append(n.Releases, ReleaseKind{Title: rg.Title, Kind: rg.Kind(), Year: year(rg.FirstReleased)}) + } + return n +} + +// began says where and when an artist began: "Formed in Leeds in 1994". +func began(a musicbrainz.Artist) string { + var verb string + switch a.Type { + case "Person": + verb = "Born" + case "Group", "Orchestra", "Choir": + verb = "Formed" + default: + return "" + } + where, y := a.BeginArea, year(a.Begin) + if where == "" && y == 0 { + return "" + } + s := verb + if where != "" { + s += " in " + where + } + if y > 0 { + s += " in " + strconv.Itoa(y) + } + return s +} + +// ended says when an artist ended: "Split up in 2012". +func ended(a musicbrainz.Artist) string { + if !a.Ended { + return "" + } + var verb string + switch a.Type { + case "Person": + verb = "Died" + case "Group", "Orchestra", "Choir": + verb = "Split up" + default: + return "" + } + if y := year(a.End); y > 0 { + return verb + " in " + strconv.Itoa(y) + } + return "" +} + +func nonNil[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/server/internal/musicbrainz/details.go b/server/internal/musicbrainz/details.go index d12f288..40900f3 100644 --- a/server/internal/musicbrainz/details.go +++ b/server/internal/musicbrainz/details.go @@ -138,7 +138,11 @@ type relation struct { Direction string `json:"direction"` Attributes []string `json:"attributes"` TargetCredit string `json:"target-credit"` - Artist *struct { + // Begin and End bound a relationship in time: a band membership. + Begin string `json:"begin"` + End string `json:"end"` + Ended bool `json:"ended"` + Artist *struct { ID string `json:"id"` Name string `json:"name"` } `json:"artist"` @@ -260,20 +264,6 @@ func (a artistDetails) artist() *Artist { if a.BeginArea != nil { out.BeginArea = a.BeginArea.Name } - for _, r := range a.Relations { - if r.URL == nil { - continue - } - switch r.Type { - case "wikidata": - if i := strings.LastIndex(r.URL.Resource, "/"); i >= 0 && out.WikidataID == "" { - out.WikidataID = r.URL.Resource[i+1:] - } - case "wikipedia": - if out.WikipediaURL == "" { - out.WikipediaURL = r.URL.Resource - } - } - } + out.WikidataID, out.WikipediaURL = links(a.Relations) return out } diff --git a/server/internal/musicbrainz/profile.go b/server/internal/musicbrainz/profile.go new file mode 100644 index 0000000..02a478f --- /dev/null +++ b/server/internal/musicbrainz/profile.go @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package musicbrainz + +import ( + "cmp" + "context" + "errors" + "fmt" + "net/url" + "slices" + "strings" + + "github.com/madeofpendletonwool/syncphony/server/internal/match" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" +) + +// What artist and album pages ask MusicBrainz: who an artist is, who's in +// the band, their genres and their releases; and where an album came out. + +// Profile is what MusicBrainz knows about an artist, for their page. +type Profile struct { + Artist + // Members are a group's members, current first. + Members []Member + // Genres are MusicBrainz's curated genres, most applied first. + Genres []string + // ReleaseGroups are their albums, singles and EPs, oldest first. + ReleaseGroups []ReleaseGroup +} + +// Member is someone in a group. +type Member struct { + Name string + MBID string + // Begin and End are when they were in it, as precise as known. + Begin, End string + Ended bool + // Roles are what they played or sang ("guitar", "lead vocals"). + Roles []string +} + +// ReleaseGroup is an album, single or EP, across its editions. +type ReleaseGroup struct { + MBID string + Title string + // PrimaryType is "Album", "Single", "EP", "Broadcast" or "Other"; + // SecondaryTypes refine it: "Compilation", "Live", "Soundtrack"... + PrimaryType string + SecondaryTypes []string + // FirstReleased is YYYY, YYYY-MM or YYYY-MM-DD, or empty. + FirstReleased string +} + +// Kind is what a page calls it: "album", "single", "ep", "compilation" or +// "live". +func (rg ReleaseGroup) Kind() string { return Kind(rg.PrimaryType, rg.SecondaryTypes) } + +// Kind names a release from its MusicBrainz types. Compilations and live +// albums are told apart from studio albums, whatever their length. +func Kind(primary string, secondary []string) string { + for _, s := range secondary { + switch strings.ToLower(s) { + case "compilation": + return "compilation" + case "live": + return "live" + } + } + switch strings.ToLower(primary) { + case "single": + return "single" + case "ep": + return "ep" + } + return "album" +} + +// maxReleaseGroups is how many of an artist's release groups are read: +// one page. +const maxReleaseGroups = 100 + +// Profile looks up an artist: two requests, at the rate limit. The +// release groups are left out if they can't be read. +func (s *Service) Profile(ctx context.Context, mbid string) (Profile, error) { + var a profileDetails + err := s.mb.get(ctx, "artist/"+url.PathEscape(mbid), url.Values{"inc": {"url-rels+artist-rels+genres"}}, &a) + if errors.Is(err, errNotFound) { + return Profile{}, fmt.Errorf("musicbrainz artist %s: %w", mbid, provider.ErrNotFound) + } + if err != nil { + return Profile{}, err + } + p := a.profile() + + var rgs struct { + ReleaseGroups []releaseGroup `json:"release-groups"` + } + switch err := s.mb.get(ctx, "release-group", url.Values{"artist": {mbid}, "limit": {fmt.Sprint(maxReleaseGroups)}}, &rgs); { + case err == nil: + for _, rg := range rgs.ReleaseGroups { + p.ReleaseGroups = append(p.ReleaseGroups, rg.group()) + } + slices.SortStableFunc(p.ReleaseGroups, func(a, b ReleaseGroup) int { + return cmp.Compare(a.FirstReleased, b.FirstReleased) + }) + case !errors.Is(err, errNotFound): + return p, err + } + return p, nil +} + +// Album is what MusicBrainz knows about an album, across its editions. +type Album struct { + ReleaseGroup + // Labels it came out on, the first edition's first. + Labels []string + Genres []string + // WikidataID is the album's Wikidata item, and WikipediaURL a + // Wikipedia article MusicBrainz links directly. + WikidataID string + WikipediaURL string +} + +// minAlbumScore is the least search score FindAlbum trusts. +const minAlbumScore = 85 + +// maxLabels caps an album's labels: reissues add more. +const maxLabels = 3 + +// FindAlbum finds an album by its artist and title: up to three requests, +// at the rate limit. Edition notes ("Deluxe Edition") don't count. It's +// provider.ErrNotFound if there's no such album. +func (s *Service) FindAlbum(ctx context.Context, artist, title string) (Album, error) { + want, _ := match.Title(title) + if want == "" || strings.TrimSpace(artist) == "" { + return Album{}, fmt.Errorf("musicbrainz album %q: %w", title, provider.ErrNotFound) + } + var r struct { + ReleaseGroups []struct { + releaseGroup + Score int `json:"score"` + ArtistCredit []artistCredit `json:"artist-credit"` + } `json:"release-groups"` + } + q := "releasegroup:" + quote(searchTitleOf(title)) + " AND artist:" + quote(artist) + err := s.mb.get(ctx, "release-group", url.Values{"query": {q}, "limit": {"5"}}, &r) + if err != nil && !errors.Is(err, errNotFound) { + return Album{}, err + } + id := "" + for _, rg := range r.ReleaseGroups { + if t, _ := match.Title(rg.Title); rg.Score >= minAlbumScore && t == want && + match.Simplify(creditName(rg.ArtistCredit)) == match.Simplify(artist) { + id = rg.ID + break + } + } + if id == "" { + return Album{}, fmt.Errorf("musicbrainz album %q by %q: %w", title, artist, provider.ErrNotFound) + } + + var rg struct { + releaseGroup + Genres []genre `json:"genres"` + Relations []relation `json:"relations"` + } + err = s.mb.get(ctx, "release-group/"+url.PathEscape(id), url.Values{"inc": {"url-rels+genres"}}, &rg) + if errors.Is(err, errNotFound) { + return Album{}, fmt.Errorf("musicbrainz release group %s: %w", id, provider.ErrNotFound) + } + if err != nil { + return Album{}, err + } + out := Album{ReleaseGroup: rg.group(), Genres: genreNames(rg.Genres)} + out.WikidataID, out.WikipediaURL = links(rg.Relations) + + var rels struct { + Releases []releaseDetails `json:"releases"` + } + switch err := s.mb.get(ctx, "release", url.Values{"release-group": {id}, "inc": {"labels"}, "limit": {"25"}}, &rels); { + case err == nil: + slices.SortStableFunc(rels.Releases, func(a, b releaseDetails) int { return cmp.Compare(dateKey(a.Date), dateKey(b.Date)) }) + for _, r := range rels.Releases { + for _, l := range r.release().Labels { + if len(out.Labels) < maxLabels && !slices.Contains(out.Labels, l) { + out.Labels = append(out.Labels, l) + } + } + } + case !errors.Is(err, errNotFound): + return out, err + } + return out, nil +} + +// searchTitleOf is an album title without its edition notes, to search for. +func searchTitleOf(title string) string { + if i := strings.IndexAny(title, "(["); i > 0 { + return strings.TrimSpace(title[:i]) + } + return title +} + +// dateKey sorts unknown dates last. +func dateKey(date string) string { + if date == "" { + return "9999" + } + return date +} + +// links finds the Wikidata item and Wikipedia article among relations. +func links(rels []relation) (wikidataID, wikipediaURL string) { + for _, r := range rels { + if r.URL == nil { + continue + } + switch r.Type { + case "wikidata": + if i := strings.LastIndex(r.URL.Resource, "/"); i >= 0 && wikidataID == "" { + wikidataID = r.URL.Resource[i+1:] + } + case "wikipedia": + if wikipediaURL == "" { + wikipediaURL = r.URL.Resource + } + } + } + return wikidataID, wikipediaURL +} + +// The JSON shapes Profile and FindAlbum read. + +type genre struct { + Name string `json:"name"` + Count int `json:"count"` +} + +// genreNames are genres' names, most applied first. +func genreNames(gs []genre) []string { + gs = slices.Clone(gs) + slices.SortStableFunc(gs, func(a, b genre) int { return cmp.Compare(b.Count, a.Count) }) + var out []string + for _, g := range gs { + if g.Count > 0 { + out = append(out, g.Name) + } + } + return out +} + +type releaseGroup struct { + ID string `json:"id"` + Title string `json:"title"` + PrimaryType string `json:"primary-type"` + SecondaryTypes []string `json:"secondary-types"` + FirstReleased string `json:"first-release-date"` +} + +func (rg releaseGroup) group() ReleaseGroup { + return ReleaseGroup{ + MBID: rg.ID, Title: rg.Title, PrimaryType: rg.PrimaryType, + SecondaryTypes: rg.SecondaryTypes, FirstReleased: rg.FirstReleased, + } +} + +type profileDetails struct { + artistDetails + Genres []genre `json:"genres"` +} + +// memberOf is the relationship between a group and its members. Seen +// from the group, it points backward. +const memberOf = "member of band" + +func (a profileDetails) profile() Profile { + p := Profile{Artist: *a.artist(), Genres: genreNames(a.Genres)} + seen := map[string]int{} + for _, r := range a.Relations { + if r.Type != memberOf || r.Direction != "backward" || r.Artist == nil { + continue + } + // Someone who left and came back is listed once, as they are now. + m := Member{ + Name: cmp.Or(r.TargetCredit, r.Artist.Name), MBID: r.Artist.ID, + Begin: r.Begin, End: r.End, Ended: r.Ended, Roles: roles(r.Attributes), + } + if i, ok := seen[m.MBID]; ok { + if !m.Ended || p.Members[i].Ended { + m.Begin = earliestDate(p.Members[i].Begin, m.Begin) + p.Members[i] = m + } + continue + } + seen[m.MBID] = len(p.Members) + p.Members = append(p.Members, m) + } + slices.SortStableFunc(p.Members, func(a, b Member) int { + if a.Ended != b.Ended { + if a.Ended { + return 1 + } + return -1 + } + return cmp.Compare(dateKey(a.Begin), dateKey(b.Begin)) + }) + return p +} + +// roles are a membership's attributes that say what someone did: +// instruments and vocals, not "original" or "founder". +func roles(attrs []string) []string { + var out []string + for _, a := range attrs { + switch a { + case "original", "founder", "additional", "guest", "minor": + default: + out = append(out, a) + } + } + return out +} + +// earliestDate of two MusicBrainz dates; empty ones don't count. +func earliestDate(a, b string) string { + switch { + case a == "": + return b + case b == "": + return a + case b < a: + return b + } + return a +} diff --git a/server/internal/musicbrainz/profile_test.go b/server/internal/musicbrainz/profile_test.go new file mode 100644 index 0000000..03f8c80 --- /dev/null +++ b/server/internal/musicbrainz/profile_test.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package musicbrainz_test + +import ( + "errors" + "fmt" + "slices" + "strings" + "testing" + + "github.com/madeofpendletonwool/syncphony/server/internal/musicbrainz" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" +) + +func TestProfile(t *testing.T) { + e := newEnv(t, -1) + e.mb.route("/ws/2/artist/"+artistID, "artist-profile.json") + e.mb.route("/ws/2/release-group", "release-groups.json") + p, err := e.svc.Profile(t.Context(), artistID) + if err != nil { + t.Fatal(err) + } + if p.Name != "The Square Roots" || p.BeginArea != "Leeds" || p.WikidataID != "Q42" || !p.Ended { + t.Errorf("artist: %+v", p.Artist) + } + if !slices.Equal(p.Genres, []string{"art rock", "alternative rock"}) { + t.Errorf("genres = %v", p.Genres) + } + var members []string + for _, m := range p.Members { + members = append(members, fmt.Sprintf("%s %s–%s %v [%s]", m.Name, m.Begin, m.End, m.Ended, strings.Join(m.Roles, ","))) + } + // Current members first; someone who left and came back once, for + // their whole time; only members, of this band. + want := []string{ + "Bo 1996– false [drums (drum set)]", + "Ann Root 1994–2012 true [guitar,lead vocals]", + "Cy Former 1994–1995 true [bass guitar]", + } + if !slices.Equal(members, want) { + t.Errorf("members:\n got %q\nwant %q", members, want) + } + var groups []string + for _, rg := range p.ReleaseGroups { + groups = append(groups, rg.Title+"="+rg.Kind()) + } + if want := []string{"Rolloff=single", "Low Pass=album", "Live at Leeds=live", "Roots: The Best Of=compilation"}; !slices.Equal(groups, want) { + t.Errorf("release groups = %v, want %v", groups, want) + } + if got := e.mb.last().URL.Query().Get("artist"); got != artistID { + t.Errorf("release groups browsed for %q", got) + } + + if _, err := e.svc.Profile(t.Context(), "nope"); !errors.Is(err, provider.ErrNotFound) { + t.Errorf("unknown artist: %v, want not found", err) + } +} + +func TestProfileWithoutReleaseGroups(t *testing.T) { + e := newEnv(t, -1) + e.mb.route("/ws/2/artist/"+artistID, "artist-profile.json") + p, err := e.svc.Profile(t.Context(), artistID) + if err != nil || p.Name == "" || len(p.ReleaseGroups) != 0 { + t.Fatalf("Profile = %+v, %v", p, err) + } +} + +func TestFindAlbum(t *testing.T) { + e := newEnv(t, -1) + e.mb.route(`/ws/2/release-group?releasegroup:"Low Pass" AND artist:"The Square Roots"`, "release-group-search.json") + e.mb.route("/ws/2/release-group/rg1", "release-group.json") + e.mb.route("/ws/2/release", "releases-labels.json") + // The edition doesn't count, and the best scored result is someone + // else's. + a, err := e.svc.FindAlbum(t.Context(), "The Square Roots", "Low Pass (Deluxe Edition)") + if err != nil { + t.Fatal(err) + } + if a.MBID != "rg1" || a.Kind() != "album" || a.FirstReleased != "1998-03-02" { + t.Errorf("album: %+v", a.ReleaseGroup) + } + if a.WikipediaURL != "https://en.wikipedia.org/wiki/Low_Pass_(album)" { + t.Errorf("wikipedia = %q", a.WikipediaURL) + } + if !slices.Equal(a.Genres, []string{"art rock", "trip hop"}) { + t.Errorf("genres = %v", a.Genres) + } + // The first edition's labels first; reissues' after, to three. + if want := []string{"Rootstock", "Square One", "Reissue Records"}; !slices.Equal(a.Labels, want) { + t.Errorf("labels = %v, want %v", a.Labels, want) + } + if got := e.mb.last().URL.Query().Get("release-group"); got != "rg1" { + t.Errorf("releases browsed for %q", got) + } + + if _, err := e.svc.FindAlbum(t.Context(), "The Square Roots", "Nothing Like It"); !errors.Is(err, provider.ErrNotFound) { + t.Errorf("unknown album: %v, want not found", err) + } +} + +func TestKind(t *testing.T) { + for _, c := range []struct { + primary string + secondary []string + want string + }{ + {"Album", nil, "album"}, + {"Single", nil, "single"}, + {"EP", nil, "ep"}, + {"Album", []string{"Compilation"}, "compilation"}, + {"Album", []string{"Live"}, "live"}, + {"Album", []string{"Soundtrack"}, "album"}, + {"", nil, "album"}, + } { + if got := musicbrainz.Kind(c.primary, c.secondary); got != c.want { + t.Errorf("Kind(%q, %v) = %q, want %q", c.primary, c.secondary, got, c.want) + } + } +} diff --git a/server/internal/musicbrainz/testdata/artist-profile.json b/server/internal/musicbrainz/testdata/artist-profile.json new file mode 100644 index 0000000..6062eb0 --- /dev/null +++ b/server/internal/musicbrainz/testdata/artist-profile.json @@ -0,0 +1,17 @@ +{"id":"0b3a1c55-2f0c-4a35-9a3e-6a1f33a0e001","name":"The Square Roots","type":"Group","country":"GB", + "area":{"name":"United Kingdom"},"begin-area":{"name":"Leeds"}, + "life-span":{"begin":"1994","end":"2012-06","ended":true}, + "genres":[{"name":"alternative rock","count":4},{"name":"art rock","count":9},{"name":"never voted","count":0}], + "relations":[ + {"type":"wikidata","target-type":"url","direction":"forward","url":{"resource":"https://www.wikidata.org/wiki/Q42"}}, + {"type":"member of band","target-type":"artist","direction":"backward","attributes":["original","guitar"],"begin":"1994","end":"1999","ended":true, + "artist":{"id":"m1","name":"Ann Root"}}, + {"type":"member of band","target-type":"artist","direction":"backward","attributes":["guitar","lead vocals"],"begin":"2003","end":"2012","ended":true, + "artist":{"id":"m1","name":"Ann Root"}}, + {"type":"member of band","target-type":"artist","direction":"backward","attributes":["drums (drum set)"],"begin":"1996","end":"","ended":false, + "artist":{"id":"m2","name":"Bo Square"},"target-credit":"Bo"}, + {"type":"member of band","target-type":"artist","direction":"backward","attributes":["bass guitar"],"begin":"1994","end":"1995","ended":true, + "artist":{"id":"m3","name":"Cy Former"}}, + {"type":"member of band","target-type":"artist","direction":"forward","attributes":[],"artist":{"id":"x","name":"Supergroup"}}, + {"type":"tribute","target-type":"artist","direction":"backward","attributes":[],"artist":{"id":"y","name":"Tribute Band"}} + ]} diff --git a/server/internal/musicbrainz/testdata/release-group-search.json b/server/internal/musicbrainz/testdata/release-group-search.json new file mode 100644 index 0000000..eed9ff3 --- /dev/null +++ b/server/internal/musicbrainz/testdata/release-group-search.json @@ -0,0 +1,3 @@ +{"release-groups":[ + {"id":"rgx","score":100,"title":"Low Pass","primary-type":"Album","artist-credit":[{"name":"Low Pass Filter","artist":{"id":"z","name":"Low Pass Filter"}}]}, + {"id":"rg1","score":96,"title":"Low Pass","primary-type":"Album","artist-credit":[{"name":"The Square Roots","artist":{"id":"0b3a1c55-2f0c-4a35-9a3e-6a1f33a0e001","name":"The Square Roots"}}]}]} diff --git a/server/internal/musicbrainz/testdata/release-group.json b/server/internal/musicbrainz/testdata/release-group.json new file mode 100644 index 0000000..b21d718 --- /dev/null +++ b/server/internal/musicbrainz/testdata/release-group.json @@ -0,0 +1,3 @@ +{"id":"rg1","title":"Low Pass","primary-type":"Album","secondary-types":[],"first-release-date":"1998-03-02", + "genres":[{"name":"trip hop","count":2},{"name":"art rock","count":5}], + "relations":[{"type":"wikipedia","target-type":"url","direction":"forward","url":{"resource":"https://en.wikipedia.org/wiki/Low_Pass_(album)"}}]} diff --git a/server/internal/musicbrainz/testdata/release-groups.json b/server/internal/musicbrainz/testdata/release-groups.json new file mode 100644 index 0000000..644a52b --- /dev/null +++ b/server/internal/musicbrainz/testdata/release-groups.json @@ -0,0 +1,5 @@ +{"release-group-count":4,"release-groups":[ + {"id":"rg3","title":"Live at Leeds","primary-type":"Album","secondary-types":["Live"],"first-release-date":"2005-02"}, + {"id":"rg1","title":"Low Pass","primary-type":"Album","secondary-types":[],"first-release-date":"1998-03-02"}, + {"id":"rg2","title":"Rolloff","primary-type":"Single","secondary-types":[],"first-release-date":"1997"}, + {"id":"rg4","title":"Roots: The Best Of","primary-type":"Album","secondary-types":["Compilation"],"first-release-date":"2010"}]} diff --git a/server/internal/musicbrainz/testdata/releases-labels.json b/server/internal/musicbrainz/testdata/releases-labels.json new file mode 100644 index 0000000..0d5f0a7 --- /dev/null +++ b/server/internal/musicbrainz/testdata/releases-labels.json @@ -0,0 +1,5 @@ +{"releases":[ + {"title":"Low Pass","date":"2015-01-01","label-info":[{"label":{"name":"Reissue Records"}}]}, + {"title":"Low Pass","date":"","label-info":[{"label":{"name":"Bootleg Co"}}]}, + {"title":"Low Pass","date":"1998-03-02","label-info":[{"label":{"name":"Rootstock"}},{"label":{"name":"[no label]"}}]}, + {"title":"Low Pass","date":"1998-05","label-info":[{"label":{"name":"Rootstock"}},{"label":{"name":"Square One"}}]}]} diff --git a/server/internal/musicgraph/genre.go b/server/internal/musicgraph/genre.go new file mode 100644 index 0000000..a2d91f1 --- /dev/null +++ b/server/internal/musicgraph/genre.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package musicgraph + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/madeofpendletonwool/syncphony/server/internal/match" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// Genres, for genre pages: the artists a tag is most applied to. + +// Genre is what's known about a genre (a tag). +type Genre struct { + Name string `json:"name"` + // Artists are its top artists, most typical first. + Artists []ArtistRef `json:"artists"` + // Sources are the sources that knew it; "cache" when only artists + // already known to carry the tag stand in. + Sources []string `json:"sources,omitempty"` +} + +// TagSource is a Source that knows a tag's top artists. LastFM is one. +type TagSource interface { + TagArtists(ctx context.Context, tag string, limit int) ([]ArtistRef, error) +} + +// maxGenreArtists is how many of a genre's artists are kept. +const maxGenreArtists = 50 + +// Genre returns a tag's top artists: from the sources that know tags, +// else from the artists cached with it. It's provider.ErrNotFound if +// nothing carries the tag. +func (s *Service) Genre(ctx context.Context, tag string) (Genre, error) { + tag = strings.TrimSpace(tag) + key := match.Simplify(tag) + if key == "" { + return Genre{}, notFound("a genre with no name") + } + now := s.opts.Now() + row, err := s.db.GetMusicGraphTag(ctx, store.GetMusicGraphTagParams{Key: key, Now: now}) + switch { + case err == nil: + if !row.Found { + return Genre{}, notFound("genre " + tag) + } + var g Genre + if err := json.Unmarshal([]byte(row.Facts), &g); err != nil { + return Genre{}, fmt.Errorf("musicgraph: reading cached genre %q: %w", key, err) + } + return g, nil + case !store.IsNotFound(err): + return Genre{}, err + } + + fctx, cancel := context.WithTimeout(ctx, s.opts.Timeout) + defer cancel() + g := Genre{Name: tag, Artists: []ArtistRef{}} + failed := false + var mu sync.Mutex + var wg sync.WaitGroup + for _, src := range s.opts.Sources { + ts, ok := src.(TagSource) + if !ok { + continue + } + wg.Go(func() { + as, err := ts.TagArtists(fctx, tag, maxGenreArtists) + mu.Lock() + defer mu.Unlock() + switch { + case err == nil && len(as) > 0: + g.Sources = append(g.Sources, src.Name()) + g.Artists = mergeRefs(g.Artists, as) + case err == nil, errors.Is(err, provider.ErrNotFound): + default: + failed = true + slog.Debug("musicgraph: asking about a genre", "source", src.Name(), "tag", tag, "err", err) + } + }) + } + wg.Wait() + + ttl := s.opts.TTL + if len(g.Artists) == 0 { + // No source knows it: the artists already known to carry it, kept + // briefly, since more are learned all the time. + ttl = s.opts.MissTTL + rows, err := s.db.TaggedArtists(ctx, store.TaggedArtistsParams{Now: now, Tag: tag, Limit: maxGenreArtists}) + if err != nil { + return Genre{}, err + } + for _, r := range rows { + g.Artists = mergeRefs(g.Artists, []ArtistRef{{Name: r.Name}}) + } + if len(g.Artists) > 0 { + g.Sources = []string{"cache"} + } + } + if failed { + ttl = s.opts.MissTTL + } + found := len(g.Artists) > 0 + facts, err := json.Marshal(g) + if err != nil { + return Genre{}, err + } + if err := s.db.PutMusicGraphTag(ctx, store.PutMusicGraphTagParams{ + Key: key, Found: found, Facts: string(facts), FetchedAt: now, ExpiresAt: now.Add(ttl), + }); err != nil { + return Genre{}, err + } + if !found { + if failed { + return Genre{}, fmt.Errorf("musicgraph: genre %q: %w", tag, provider.ErrUnavailable) + } + return Genre{}, notFound("genre " + tag) + } + return g, nil +} + +// mergeRefs adds artists not already in a list, by name, up to the most +// a genre keeps. +func mergeRefs(have, more []ArtistRef) []ArtistRef { + seen := map[string]bool{} + for _, a := range have { + seen[artistKey(a)] = true + } + for _, a := range more { + if k := artistKey(a); k != "" && !seen[k] && len(have) < maxGenreArtists { + seen[k] = true + have = append(have, a) + } + } + return have +} + +// TagArtists implements TagSource with tag.getTopArtists. +func (l *LastFM) TagArtists(ctx context.Context, tag string, limit int) ([]ArtistRef, error) { + var r struct { + TopArtists struct { + Artist []struct { + Name string `json:"name"` + MBID string `json:"mbid"` + } `json:"artist"` + } `json:"topartists"` + } + if err := l.call(ctx, "tag.getTopArtists", url.Values{"tag": {tag}, "limit": {strconv.Itoa(limit)}}, &r); err != nil { + return nil, err + } + var out []ArtistRef + for _, a := range r.TopArtists.Artist { + out = append(out, ArtistRef{Name: a.Name, MBID: a.MBID}) + } + return out, nil +} + +var _ TagSource = (*LastFM)(nil) diff --git a/server/internal/musicgraph/genre_test.go b/server/internal/musicgraph/genre_test.go new file mode 100644 index 0000000..436ba4b --- /dev/null +++ b/server/internal/musicgraph/genre_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package musicgraph_test + +import ( + "errors" + "slices" + "testing" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" +) + +func refNames(as []musicgraph.ArtistRef) []string { + var out []string + for _, a := range as { + out = append(out, a.Name) + } + return out +} + +func TestGenreFromLastFM(t *testing.T) { + e := newEnv(t) + e.http.routes["/lastfm/?tag.getTopArtists&trip hop"] = `{"topartists":{"artist":[ + {"name":"Massive Attack","mbid":"ma"},{"name":"Portishead","mbid":""},{"name":"massive attack","mbid":""}]}}` + g, err := e.svc.Genre(t.Context(), "trip hop") + if err != nil { + t.Fatal(err) + } + if want := []string{"Massive Attack", "Portishead"}; !slices.Equal(refNames(g.Artists), want) { + t.Errorf("artists = %v, want %v", refNames(g.Artists), want) + } + if !slices.Equal(g.Sources, []string{"lastfm"}) || g.Artists[0].MBID != "ma" { + t.Errorf("genre: %+v", g) + } + // Cached, under any spelling. + n := e.http.count() + if _, err := e.svc.Genre(t.Context(), "Trip-Hop"); err != nil || e.http.count() != n { + t.Errorf("not cached: %v, %d requests", err, e.http.count()-n) + } +} + +func TestGenreFromCachedArtists(t *testing.T) { + e := newEnv(t) + // Radiohead's tags are learned when asked about them. + if _, err := e.svc.Artist(t.Context(), radiohead); err != nil { + t.Fatal(err) + } + g, err := e.svc.Genre(t.Context(), "Art Rock") + if err != nil { + t.Fatal(err) + } + if !slices.Equal(refNames(g.Artists), []string{"Radiohead"}) || !slices.Equal(g.Sources, []string{"cache"}) { + t.Errorf("genre: %+v", g) + } + // Kept briefly: more artists with the tag may be learned. + e.now = e.now.Add(2 * time.Hour) + e.http.routes["/lastfm/?tag.getTopArtists&Art Rock"] = `{"topartists":{"artist":[{"name":"Pink Floyd"}]}}` + if g, _ := e.svc.Genre(t.Context(), "Art Rock"); !slices.Equal(refNames(g.Artists), []string{"Pink Floyd"}) { + t.Errorf("after expiry: %v", refNames(g.Artists)) + } +} + +func TestUnknownGenre(t *testing.T) { + e := newEnv(t) + if _, err := e.svc.Genre(t.Context(), "nothing at all"); !errors.Is(err, provider.ErrNotFound) { + t.Errorf("unknown genre: %v", err) + } + if _, err := e.svc.Genre(t.Context(), " "); !errors.Is(err, provider.ErrNotFound) { + t.Errorf("no name: %v", err) + } +} diff --git a/server/internal/musicgraph/musicgraph.go b/server/internal/musicgraph/musicgraph.go index 3aa335c..973e494 100644 --- a/server/internal/musicgraph/musicgraph.go +++ b/server/internal/musicgraph/musicgraph.go @@ -651,6 +651,9 @@ func (s *Service) Sweep(ctx context.Context) error { if err := s.db.DeleteExpiredMusicGraphArtists(ctx, now); err != nil { return err } + if err := s.db.DeleteExpiredMusicGraphTags(ctx, now); err != nil { + return err + } return s.db.DeleteExpiredMusicGraphTracks(ctx, now) } diff --git a/server/internal/musicgraph/musicgraph_test.go b/server/internal/musicgraph/musicgraph_test.go index c9040d9..101f3b8 100644 --- a/server/internal/musicgraph/musicgraph_test.go +++ b/server/internal/musicgraph/musicgraph_test.go @@ -94,7 +94,7 @@ func route(r *http.Request) string { q := r.URL.Query() switch { case strings.HasPrefix(r.URL.Path, "/lastfm/"): - k := r.URL.Path + "?" + q.Get("method") + "&" + q.Get("artist") + k := r.URL.Path + "?" + q.Get("method") + "&" + q.Get("artist") + q.Get("tag") if q.Get("track") != "" { k += "&" + q.Get("track") } diff --git a/server/internal/provider/navidrome/client.go b/server/internal/provider/navidrome/client.go index 0c6ac30..587b107 100644 --- a/server/internal/provider/navidrome/client.go +++ b/server/internal/provider/navidrome/client.go @@ -288,6 +288,10 @@ type album struct { SongCount int `json:"songCount"` CoverArt string `json:"coverArt"` Song []song `json:"song"` + // OpenSubsonic: MusicBrainz-style release types ("Album", "EP", + // "Compilation", "Live"...), and whether it's a compilation. + ReleaseTypes []string `json:"releaseTypes"` + IsCompilation bool `json:"isCompilation"` } type artist struct { diff --git a/server/internal/provider/navidrome/navidrome_test.go b/server/internal/provider/navidrome/navidrome_test.go index f5419dc..773febf 100644 --- a/server/internal/provider/navidrome/navidrome_test.go +++ b/server/internal/provider/navidrome/navidrome_test.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" "strings" "sync" "sync/atomic" @@ -92,6 +93,13 @@ func (s *server) route(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "audio/flac") http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(audio)) + case "getArtist": + ok(w, `"artist":{"id":"ar1","name":"The Square Roots","album":[`+ + `{"id":"al1","name":"Low Pass","releaseTypes":["Album"]},`+ + `{"id":"al2","name":"Rolloff","releaseTypes":["Single"]},`+ + `{"id":"al3","name":"Live at Leeds","releaseTypes":["Album","Live"]},`+ + `{"id":"al4","name":"Hits","releaseTypes":["Album"],"isCompilation":true},`+ + `{"id":"al5","name":"Plain Subsonic"}]}`) case "getCoverArt": w.Header().Set("Content-Type", "image/png") _, _ = w.Write([]byte("png:" + q.Get("id") + ":" + q.Get("size"))) //nolint:gosec // test server @@ -336,6 +344,23 @@ func TestTrackMetadata(t *testing.T) { } } +func TestAlbumKinds(t *testing.T) { + srv := newServer(t) + sess := open(t, navidrome.New(navidrome.Options{}), srv.URL) + _, albums, err := sess.Artist(t.Context(), "ar1") + if err != nil { + t.Fatal(err) + } + var got []string + for _, a := range albums { + got = append(got, a.Title+"="+a.Kind) + } + want := []string{"Low Pass=album", "Rolloff=single", "Live at Leeds=live", "Hits=compilation", "Plain Subsonic="} + if !slices.Equal(got, want) { + t.Errorf("kinds = %v, want %v", got, want) + } +} + func TestStreamFormat(t *testing.T) { srv := newServer(t) sess := open(t, navidrome.New(navidrome.Options{}), srv.URL) diff --git a/server/internal/provider/navidrome/session.go b/server/internal/provider/navidrome/session.go index fbcbe10..56690d0 100644 --- a/server/internal/provider/navidrome/session.go +++ b/server/internal/provider/navidrome/session.go @@ -183,9 +183,36 @@ func toAlbum(al album) provider.Album { Year: al.Year, TrackCount: al.SongCount, Artwork: provider.ArtworkRef(al.CoverArt), + Kind: albumKind(al), } } +// albumKind is a provider album kind from OpenSubsonic's release types, +// which are MusicBrainz's. Empty if the album has none. +func albumKind(al album) string { + kind := "" + for _, t := range al.ReleaseTypes { + switch strings.ToLower(t) { + case "compilation": + return provider.AlbumKindCompilation + case "live": + return provider.AlbumKindLive + case "album": + if kind == "" { + kind = provider.AlbumKindAlbum + } + case "single": + kind = provider.AlbumKindSingle + case "ep": + kind = provider.AlbumKindEP + } + } + if al.IsCompilation { + return provider.AlbumKindCompilation + } + return kind +} + func toArtist(ar artist) provider.Artist { art := ar.CoverArt if art == "" && ar.ID != "" { diff --git a/server/internal/provider/spotify/client.go b/server/internal/provider/spotify/client.go index 22f6fab..0456cb5 100644 --- a/server/internal/provider/spotify/client.go +++ b/server/internal/provider/spotify/client.go @@ -173,6 +173,8 @@ type album struct { ReleaseDate string `json:"release_date"` // "2024", "2024-03" or "2024-03-01" TotalTracks int `json:"total_tracks"` Images []Image `json:"images"` + // AlbumType is "album", "single" (EPs too) or "compilation". + AlbumType string `json:"album_type"` // Tracks is only set by GET /albums/{id}, and its tracks have no album. Tracks *paging[track] `json:"tracks"` } diff --git a/server/internal/provider/spotify/session.go b/server/internal/provider/spotify/session.go index c77dc6e..36d1cf9 100644 --- a/server/internal/provider/spotify/session.go +++ b/server/internal/provider/spotify/session.go @@ -254,9 +254,23 @@ func (s *session) album(a album) provider.Album { Year: year, TrackCount: a.TotalTracks, Artwork: s.artworkRef(a.Images), + Kind: albumKind(a.AlbumType), } } +// albumKind is a provider album kind from Spotify's album_type. +func albumKind(t string) string { + switch t { + case "album": + return provider.AlbumKindAlbum + case "single": + return provider.AlbumKindSingle + case "compilation": + return provider.AlbumKindCompilation + } + return "" +} + func (s *session) playlist(pl playlist) provider.Playlist { out := provider.Playlist{ID: pl.ID, Name: pl.Name, Owner: pl.Owner.DisplayName, Artwork: s.artworkRef(pl.Images)} if out.Owner == "" { diff --git a/server/internal/provider/types.go b/server/internal/provider/types.go index 342451d..f62d1bc 100644 --- a/server/internal/provider/types.go +++ b/server/internal/provider/types.go @@ -57,8 +57,21 @@ type Album struct { Year int TrackCount int Artwork ArtworkRef + // Kind is one of the AlbumKind constants, when the service says; + // empty when it doesn't. + Kind string `json:",omitempty"` } +// Album kinds. Services that can't tell an EP from a single call both +// singles. +const ( + AlbumKindAlbum = "album" + AlbumKindSingle = "single" + AlbumKindEP = "ep" + AlbumKindCompilation = "compilation" + AlbumKindLive = "live" +) + // Artist is normalized artist metadata. type Artist struct { ID string diff --git a/server/internal/store/liner_notes.sql.gen.go b/server/internal/store/liner_notes.sql.gen.go index dc8268f..9a13f67 100644 --- a/server/internal/store/liner_notes.sql.gen.go +++ b/server/internal/store/liner_notes.sql.gen.go @@ -19,6 +19,15 @@ func (q *Queries) DeleteExpiredLinerNotes(ctx context.Context, expiresAt time.Ti return err } +const deleteExpiredPageNotes = `-- name: DeleteExpiredPageNotes :exec +DELETE FROM page_notes_cache WHERE expires_at <= ? +` + +func (q *Queries) DeleteExpiredPageNotes(ctx context.Context, expiresAt time.Time) error { + _, err := q.db.ExecContext(ctx, deleteExpiredPageNotes, expiresAt) + return err +} + const getCachedLinerNotes = `-- name: GetCachedLinerNotes :one SELECT provider, track_id, found, notes, fetched_at, expires_at FROM liner_notes_cache WHERE provider = ? AND track_id = ? AND expires_at > ?3 ` @@ -43,6 +52,30 @@ func (q *Queries) GetCachedLinerNotes(ctx context.Context, arg GetCachedLinerNot return i, err } +const getCachedPageNotes = `-- name: GetCachedPageNotes :one +SELECT kind, "key", found, notes, fetched_at, expires_at FROM page_notes_cache WHERE kind = ? AND key = ? AND expires_at > ?3 +` + +type GetCachedPageNotesParams struct { + Kind string + Key string + Now time.Time +} + +func (q *Queries) GetCachedPageNotes(ctx context.Context, arg GetCachedPageNotesParams) (PageNotesCache, error) { + row := q.db.QueryRowContext(ctx, getCachedPageNotes, arg.Kind, arg.Key, arg.Now) + var i PageNotesCache + err := row.Scan( + &i.Kind, + &i.Key, + &i.Found, + &i.Notes, + &i.FetchedAt, + &i.ExpiresAt, + ) + return i, err +} + const putCachedLinerNotes = `-- name: PutCachedLinerNotes :exec INSERT INTO liner_notes_cache (provider, track_id, found, notes, fetched_at, expires_at) VALUES (?, ?, ?, ?, ?, ?) @@ -70,3 +103,31 @@ func (q *Queries) PutCachedLinerNotes(ctx context.Context, arg PutCachedLinerNot ) return err } + +const putCachedPageNotes = `-- name: PutCachedPageNotes :exec +INSERT INTO page_notes_cache (kind, key, found, notes, fetched_at, expires_at) +VALUES (?, ?, ?, ?, ?, ?) +ON CONFLICT (kind, key) DO UPDATE SET + found = excluded.found, notes = excluded.notes, fetched_at = excluded.fetched_at, expires_at = excluded.expires_at +` + +type PutCachedPageNotesParams struct { + Kind string + Key string + Found bool + Notes string + FetchedAt time.Time + ExpiresAt time.Time +} + +func (q *Queries) PutCachedPageNotes(ctx context.Context, arg PutCachedPageNotesParams) error { + _, err := q.db.ExecContext(ctx, putCachedPageNotes, + arg.Kind, + arg.Key, + arg.Found, + arg.Notes, + arg.FetchedAt, + arg.ExpiresAt, + ) + return err +} diff --git a/server/internal/store/migrations/00022_artist_pages.sql b/server/internal/store/migrations/00022_artist_pages.sql new file mode 100644 index 0000000..149bb86 --- /dev/null +++ b/server/internal/store/migrations/00022_artist_pages.sql @@ -0,0 +1,33 @@ +-- Artist and album pages (MAD-763). Their notes: an artist's bio, facts, +-- members, genres and releases, an album's type, labels and notes, from +-- MusicBrainz and Wikipedia. And a genre's top artists, from the music +-- knowledge layer. Misses are kept too, with found false. + +-- +goose Up +CREATE TABLE page_notes_cache ( + kind TEXT NOT NULL CHECK (kind IN ('artist', 'album')), + -- The artist's simplified name; for an album, its artist's and its + -- title's, joined by a NUL. + key TEXT NOT NULL, + found BOOLEAN NOT NULL, + -- linernotes.ArtistNotes or linernotes.AlbumNotes as JSON; '{}' when + -- not found. + notes TEXT NOT NULL, + fetched_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + PRIMARY KEY (kind, key) +); + +CREATE TABLE musicgraph_tags ( + -- The tag, simplified (match.Simplify). + key TEXT PRIMARY KEY, + found BOOLEAN NOT NULL, + -- musicgraph.Genre as JSON. + facts TEXT NOT NULL, + fetched_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL +); + +-- +goose Down +DROP TABLE musicgraph_tags; +DROP TABLE page_notes_cache; diff --git a/server/internal/store/models.gen.go b/server/internal/store/models.gen.go index 9ae481d..6315ce6 100644 --- a/server/internal/store/models.gen.go +++ b/server/internal/store/models.gen.go @@ -137,6 +137,14 @@ type MusicgraphArtist struct { ExpiresAt time.Time } +type MusicgraphTag struct { + Key string + Found bool + Facts string + FetchedAt time.Time + ExpiresAt time.Time +} + type MusicgraphTrack struct { Key string Found bool @@ -156,6 +164,15 @@ type Night struct { Hearts int64 } +type PageNotesCache struct { + Kind string + Key string + Found bool + Notes string + FetchedAt time.Time + ExpiresAt time.Time +} + type PlayHistory struct { ID string RoomID string diff --git a/server/internal/store/musicgraph.sql.gen.go b/server/internal/store/musicgraph.sql.gen.go index 17b75d9..dd89f39 100644 --- a/server/internal/store/musicgraph.sql.gen.go +++ b/server/internal/store/musicgraph.sql.gen.go @@ -19,6 +19,15 @@ func (q *Queries) DeleteExpiredMusicGraphArtists(ctx context.Context, expiresAt return err } +const deleteExpiredMusicGraphTags = `-- name: DeleteExpiredMusicGraphTags :exec +DELETE FROM musicgraph_tags WHERE expires_at <= ? +` + +func (q *Queries) DeleteExpiredMusicGraphTags(ctx context.Context, expiresAt time.Time) error { + _, err := q.db.ExecContext(ctx, deleteExpiredMusicGraphTags, expiresAt) + return err +} + const deleteExpiredMusicGraphTracks = `-- name: DeleteExpiredMusicGraphTracks :exec DELETE FROM musicgraph_tracks WHERE expires_at <= ? ` @@ -75,6 +84,28 @@ func (q *Queries) GetMusicGraphArtistByMBID(ctx context.Context, arg GetMusicGra return i, err } +const getMusicGraphTag = `-- name: GetMusicGraphTag :one +SELECT "key", found, facts, fetched_at, expires_at FROM musicgraph_tags WHERE key = ? AND expires_at > ?2 +` + +type GetMusicGraphTagParams struct { + Key string + Now time.Time +} + +func (q *Queries) GetMusicGraphTag(ctx context.Context, arg GetMusicGraphTagParams) (MusicgraphTag, error) { + row := q.db.QueryRowContext(ctx, getMusicGraphTag, arg.Key, arg.Now) + var i MusicgraphTag + err := row.Scan( + &i.Key, + &i.Found, + &i.Facts, + &i.FetchedAt, + &i.ExpiresAt, + ) + return i, err +} + const getMusicGraphTrack = `-- name: GetMusicGraphTrack :one SELECT "key", found, facts, fetched_at, expires_at FROM musicgraph_tracks WHERE key = ? AND expires_at > ?2 ` @@ -193,6 +224,33 @@ func (q *Queries) PutMusicGraphArtist(ctx context.Context, arg PutMusicGraphArti return err } +const putMusicGraphTag = `-- name: PutMusicGraphTag :exec +INSERT INTO musicgraph_tags (key, found, facts, fetched_at, expires_at) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT (key) DO UPDATE SET + found = excluded.found, facts = excluded.facts, + fetched_at = excluded.fetched_at, expires_at = excluded.expires_at +` + +type PutMusicGraphTagParams struct { + Key string + Found bool + Facts string + FetchedAt time.Time + ExpiresAt time.Time +} + +func (q *Queries) PutMusicGraphTag(ctx context.Context, arg PutMusicGraphTagParams) error { + _, err := q.db.ExecContext(ctx, putMusicGraphTag, + arg.Key, + arg.Found, + arg.Facts, + arg.FetchedAt, + arg.ExpiresAt, + ) + return err +} + const putMusicGraphTrack = `-- name: PutMusicGraphTrack :exec INSERT INTO musicgraph_tracks (key, found, facts, fetched_at, expires_at) VALUES (?, ?, ?, ?, ?) @@ -219,3 +277,49 @@ func (q *Queries) PutMusicGraphTrack(ctx context.Context, arg PutMusicGraphTrack ) return err } + +const taggedArtists = `-- name: TaggedArtists :many +SELECT CAST(json_extract(musicgraph_artists.facts, '$.ref.name') AS TEXT) AS name, + CAST(json_extract(tag.value, '$.weight') AS REAL) AS weight +FROM musicgraph_artists, json_each(musicgraph_artists.facts, '$.tags') AS tag +WHERE musicgraph_artists.found AND musicgraph_artists.expires_at > ?1 + AND lower(json_extract(tag.value, '$.name')) = lower(?2) +ORDER BY weight DESC +LIMIT ?3 +` + +type TaggedArtistsParams struct { + Now time.Time + Tag string + Limit int64 +} + +type TaggedArtistsRow struct { + Name string + Weight float64 +} + +// TaggedArtists are cached artists carrying a tag, most strongly first: +// a genre's artists when no source can say. +func (q *Queries) TaggedArtists(ctx context.Context, arg TaggedArtistsParams) ([]TaggedArtistsRow, error) { + rows, err := q.db.QueryContext(ctx, taggedArtists, arg.Now, arg.Tag, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []TaggedArtistsRow{} + for rows.Next() { + var i TaggedArtistsRow + if err := rows.Scan(&i.Name, &i.Weight); 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 +} diff --git a/server/internal/store/queries/liner_notes.sql b/server/internal/store/queries/liner_notes.sql index b1361c7..8c48139 100644 --- a/server/internal/store/queries/liner_notes.sql +++ b/server/internal/store/queries/liner_notes.sql @@ -9,3 +9,15 @@ ON CONFLICT (provider, track_id) DO UPDATE SET -- name: DeleteExpiredLinerNotes :exec DELETE FROM liner_notes_cache WHERE expires_at <= ?; + +-- name: GetCachedPageNotes :one +SELECT * FROM page_notes_cache WHERE kind = ? AND key = ? AND expires_at > sqlc.arg(now); + +-- name: PutCachedPageNotes :exec +INSERT INTO page_notes_cache (kind, key, found, notes, fetched_at, expires_at) +VALUES (?, ?, ?, ?, ?, ?) +ON CONFLICT (kind, key) DO UPDATE SET + found = excluded.found, notes = excluded.notes, fetched_at = excluded.fetched_at, expires_at = excluded.expires_at; + +-- name: DeleteExpiredPageNotes :exec +DELETE FROM page_notes_cache WHERE expires_at <= ?; diff --git a/server/internal/store/queries/musicgraph.sql b/server/internal/store/queries/musicgraph.sql index 67d4b49..3b2bbe3 100644 --- a/server/internal/store/queries/musicgraph.sql +++ b/server/internal/store/queries/musicgraph.sql @@ -33,3 +33,27 @@ SELECT * FROM musicgraph_artists WHERE expires_at > sqlc.arg(now) ORDER BY fetch -- name: ListMusicGraphTracks :many SELECT * FROM musicgraph_tracks WHERE expires_at > sqlc.arg(now) ORDER BY fetched_at; + +-- name: GetMusicGraphTag :one +SELECT * FROM musicgraph_tags WHERE key = ? AND expires_at > sqlc.arg(now); + +-- name: PutMusicGraphTag :exec +INSERT INTO musicgraph_tags (key, found, facts, fetched_at, expires_at) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT (key) DO UPDATE SET + found = excluded.found, facts = excluded.facts, + fetched_at = excluded.fetched_at, expires_at = excluded.expires_at; + +-- name: DeleteExpiredMusicGraphTags :exec +DELETE FROM musicgraph_tags WHERE expires_at <= ?; + +-- TaggedArtists are cached artists carrying a tag, most strongly first: +-- a genre's artists when no source can say. +-- name: TaggedArtists :many +SELECT CAST(json_extract(musicgraph_artists.facts, '$.ref.name') AS TEXT) AS name, + CAST(json_extract(tag.value, '$.weight') AS REAL) AS weight +FROM musicgraph_artists, json_each(musicgraph_artists.facts, '$.tags') AS tag +WHERE musicgraph_artists.found AND musicgraph_artists.expires_at > sqlc.arg(now) + AND lower(json_extract(tag.value, '$.name')) = lower(sqlc.arg(tag)) +ORDER BY weight DESC +LIMIT sqlc.arg(limit); From 3f347c2015f7ca319faad5c8d3156cc0135153e1 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 7 Oct 2026 18:59:48 -0500 Subject: [PATCH 2/3] Artist page API: about, top songs, related, shuffle, elsewhere, genres (MAD-766..771) - stats.ArtistTop tallies an artist's most played songs in a room. - artistpage fills artist and genre pages through one link: top songs from the music knowledge layer matched onto the link (one search for the artist's songs, then a few lookups), else the service's top tracks, else its search order; songs on others' albums they're credited on; similar artists found on the link with a few songs each; a genre's artists, those on the link first; shuffles across albums; and the same artist on another link. - New endpoints: GET /links/{id}/artists/{artistId}/about (bio, facts, members, genres from MusicBrainz then strong tags, kinds of albums the service doesn't say), /tracks, /related, /shuffle, /elsewhere; /links/{id}/albums/{albumId}/about; /links/{id}/genre?name=; and /rooms/{roomId}/artist-plays?name= (stats.ArtistTop). - AlbumResult gains kind. Server gains Graph, set when a music knowledge source is on. Co-Authored-By: Claude Opus 5.5 --- api/openapi.yaml | 420 +++++ server/cmd/syncphony/main.go | 3 + server/internal/artistpage/artistpage.go | 448 ++++++ server/internal/artistpage/artistpage_test.go | 218 +++ server/internal/httpapi/api.gen.go | 1369 +++++++++++++++-- server/internal/httpapi/artistpage.go | 352 +++++ server/internal/httpapi/artistpage_test.go | 124 ++ server/internal/httpapi/browse.go | 3 + server/internal/httpapi/musicbrainz_test.go | 31 + server/internal/httpapi/server.go | 4 + server/internal/stats/stats.go | 30 +- server/internal/stats/stats_test.go | 21 + 12 files changed, 2877 insertions(+), 146 deletions(-) create mode 100644 server/internal/artistpage/artistpage.go create mode 100644 server/internal/artistpage/artistpage_test.go create mode 100644 server/internal/httpapi/artistpage.go create mode 100644 server/internal/httpapi/artistpage_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 00326c6..a46b6b4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1265,6 +1265,224 @@ paths: default: $ref: "#/components/responses/Error" + /links/{id}/artists/{artistId}/about: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: artistId + in: path + required: true + schema: + type: string + get: + operationId: getArtistAbout + summary: What's known about an artist + description: | + Their bio from Wikipedia, facts, members and genres, from + MusicBrainz and the music knowledge layer, and the kind of each of + their albums the service doesn't say. Parts nobody knows are left + empty. The first time, it can take several seconds; then it's cached. + tags: [browse] + responses: + "200": + description: What's known about an artist + content: + application/json: + schema: + $ref: "#/components/schemas/ArtistAbout" + default: + $ref: "#/components/responses/Error" + + /links/{id}/artists/{artistId}/tracks: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: artistId + in: path + required: true + schema: + type: string + get: + operationId: getArtistTracks + summary: An artist's top songs and appearances + description: | + Their most popular songs, from what's known about music and else + the service, matched onto this link, and songs on others' records + they're credited on. + tags: [browse] + responses: + "200": + description: An artist's top songs and appearances + content: + application/json: + schema: + $ref: "#/components/schemas/ArtistTracks" + default: + $ref: "#/components/responses/Error" + + /links/{id}/artists/{artistId}/related: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: artistId + in: path + required: true + schema: + type: string + get: + operationId: getArtistRelated + summary: Artists like an artist, and songs by them + description: | + Similar artists, most alike first, with `artist` set for those on + this link, and a few songs by each that are. + tags: [browse] + responses: + "200": + description: Artists like an artist, and songs by them + content: + application/json: + schema: + $ref: "#/components/schemas/RelatedMusic" + default: + $ref: "#/components/responses/Error" + + /links/{id}/artists/{artistId}/elsewhere: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: artistId + in: path + required: true + schema: + type: string + get: + operationId: getArtistElsewhere + summary: The same artist on your other services + description: | + The artist, by name, on the other links you may use. + tags: [browse] + responses: + "200": + description: The same artist on your other services + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ArtistElsewhere" + default: + $ref: "#/components/responses/Error" + + /links/{id}/artists/{artistId}/shuffle: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: artistId + in: path + required: true + schema: + type: string + get: + operationId: getArtistShuffle + summary: Songs by an artist at random + description: | + Picked from across their albums. + tags: [browse] + parameters: + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 50 + default: 10 + responses: + "200": + description: Songs by an artist at random + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TrackResult" + default: + $ref: "#/components/responses/Error" + + /links/{id}/albums/{albumId}/about: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: albumId + in: path + required: true + schema: + type: string + get: + operationId: getAlbumAbout + summary: What's known about an album + description: | + Its kind, first release, labels and genres from MusicBrainz, and a + summary from Wikipedia. 404 if MusicBrainz doesn't know it, or is off. + tags: [browse] + responses: + "200": + description: What's known about the album + content: + application/json: + schema: + $ref: "#/components/schemas/AlbumAbout" + default: + $ref: "#/components/responses/Error" + + /links/{id}/genre: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + operationId: getGenre + summary: A genre's artists, and songs by them + description: | + Its top artists, those on this link first, with a few songs by + each. 404 if nothing is known about it. + tags: [browse] + parameters: + - name: name + in: query + required: true + schema: + type: string + minLength: 1 + responses: + "200": + description: The genre + content: + application/json: + schema: + $ref: "#/components/schemas/GenreDetail" + default: + $ref: "#/components/responses/Error" + /links/{id}/playlists: parameters: - name: id @@ -1599,6 +1817,42 @@ paths: default: $ref: "#/components/responses/Error" + /rooms/{roomId}/artist-plays: + parameters: + - $ref: "#/components/parameters/RoomId" + get: + operationId: getRoomArtistPlays + summary: An artist's songs the room played most + description: | + Songs credited to the artist, by name, that played to the end in + this room, most plays first. + tags: [history] + parameters: + - name: name + in: query + required: true + schema: + type: string + minLength: 1 + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 50 + default: 10 + responses: + "200": + description: The artist's most played songs + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TrackCount" + default: + $ref: "#/components/responses/Error" + /rooms/{roomId}/history: parameters: - $ref: "#/components/parameters/RoomId" @@ -3802,6 +4056,13 @@ components: type: integer artwork: type: string + kind: + $ref: "#/components/schemas/AlbumKind" + + AlbumKind: + type: string + description: What the service says it is. Services that can't tell an EP from a single call both singles. + enum: [album, single, ep, compilation, live] ArtistResult: type: object @@ -4156,6 +4417,165 @@ components: items: $ref: "#/components/schemas/AlbumResult" + ArtistAbout: + type: object + required: [name, facts, members, genres, albumKinds] + properties: + name: + type: string + mbid: + type: string + about: + type: string + description: A one-liner, like "Group from Seattle". + bio: + type: string + description: A short summary from Wikipedia. + bioUrl: + type: string + description: The Wikipedia article. + facts: + type: array + items: + $ref: "#/components/schemas/ArtistFact" + members: + type: array + description: A group's members, current first. + items: + $ref: "#/components/schemas/ArtistMember" + genres: + type: array + description: Most fitting first. + items: + type: string + albumKinds: + type: object + description: The kind of each of the artist's albums the service doesn't say, by album ID. + additionalProperties: + $ref: "#/components/schemas/AlbumKind" + + ArtistFact: + type: object + required: [kind, text] + properties: + kind: + type: string + enum: [began, ended] + text: + type: string + description: '"Formed in Leeds in 1994", "Split up in 2012"...' + + ArtistMember: + type: object + required: [name, current, roles] + properties: + name: + type: string + from: + type: integer + description: The year they joined. + to: + type: integer + description: The year they left. + current: + type: boolean + roles: + type: array + items: + type: string + + ArtistTracks: + type: object + required: [top, appearsOn] + properties: + top: + type: array + description: Most popular first. + items: + $ref: "#/components/schemas/TrackResult" + appearsOn: + type: array + description: Songs on others' records they're credited on. + items: + $ref: "#/components/schemas/TrackResult" + + RelatedArtist: + type: object + required: [name] + properties: + name: + type: string + score: + type: number + description: How alike, from 0 to 1, when known. + artist: + $ref: "#/components/schemas/ArtistResult" + + RelatedMusic: + type: object + required: [artists, tracks] + properties: + artists: + type: array + items: + $ref: "#/components/schemas/RelatedArtist" + tracks: + type: array + items: + $ref: "#/components/schemas/TrackResult" + + GenreDetail: + type: object + required: [name, artists, tracks] + properties: + name: + type: string + artists: + type: array + description: Those on the link first. + items: + $ref: "#/components/schemas/RelatedArtist" + tracks: + type: array + items: + $ref: "#/components/schemas/TrackResult" + + ArtistElsewhere: + type: object + required: [linkId, provider, accountLabel, artist] + properties: + linkId: + type: string + provider: + type: string + accountLabel: + type: string + artist: + $ref: "#/components/schemas/ArtistResult" + + AlbumAbout: + type: object + required: [labels, genres] + properties: + kind: + $ref: "#/components/schemas/AlbumKind" + firstReleased: + type: string + description: YYYY, YYYY-MM or YYYY-MM-DD. + labels: + type: array + items: + type: string + genres: + type: array + items: + type: string + about: + type: string + description: A short summary from Wikipedia. + aboutUrl: + type: string + PlaylistResult: type: object required: [id, name] diff --git a/server/cmd/syncphony/main.go b/server/cmd/syncphony/main.go index 17b6ecf..546166e 100644 --- a/server/cmd/syncphony/main.go +++ b/server/cmd/syncphony/main.go @@ -250,6 +250,9 @@ func run() error { Bus: a.bus, Presence: presence, BaseURL: cfg.BaseURL, TrustedProxies: cfg.TrustedProxies, } + if len(graph.Sources()) > 0 { + api.Graph = graph + } go tick(ctx, api, nightSvc) mux := http.NewServeMux() mux.Handle("/api/", api.Handler()) diff --git a/server/internal/artistpage/artistpage.go b/server/internal/artistpage/artistpage.go new file mode 100644 index 0000000..c798571 --- /dev/null +++ b/server/internal/artistpage/artistpage.go @@ -0,0 +1,448 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package artistpage fills artist and genre pages from what's known about +// music (internal/musicgraph) and one linked service: an artist's top +// songs and the songs they're featured on, artists like them and songs +// by those, a genre's artists, and the same artist on other services. +// +// What's known says what to show, the service what can be played: songs +// and artists are looked up on the link, and only those found are shown +// as playable. +package artistpage + +import ( + "context" + "log/slog" + "math/rand/v2" + "slices" + "sync" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/match" + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" +) + +// Graph is what's known about music. musicgraph.Service is one. +type Graph interface { + Artist(ctx context.Context, a musicgraph.ArtistRef) (musicgraph.Artist, error) + CachedArtist(ctx context.Context, a musicgraph.ArtistRef) (musicgraph.Artist, bool, error) + Genre(ctx context.Context, tag string) (musicgraph.Genre, error) + WarmArtist(as ...musicgraph.ArtistRef) +} + +// Limits, so a page stays quick on a service that searches slowly. +const ( + // TopSongs is how many of an artist's top songs a page shows. + TopSongs = 10 + // maxAppearances is how many songs they're featured on it shows. + maxAppearances = 20 + // poolSize is how many of an artist's songs one search finds, to + // match their top songs against. + poolSize = 50 + // maxLookups is how many top songs missing from that search are + // looked for one by one. + maxLookups = 6 + // RelatedArtists is how many similar artists a page shows, and + // songsEach how many songs by each. + RelatedArtists = 12 + songsEach = 2 + // GenreArtists is how many of a genre's artists are looked for. + GenreArtists = 24 + // parallel is how many lookups run at once on one service. + parallel = 4 + // graphWait bounds waiting on what's known about an artist the first + // time; the page falls back to what the service says. + graphWait = 12 * time.Second +) + +// Page fills artist pages through one link's session. +type Page struct { + Sess provider.Session + // Caps are the link's provider's capabilities. + Caps provider.Capabilities + // Graph is what's known about music; nil without it. + Graph Graph +} + +// Top returns an artist's most popular songs on the service, most popular +// first, and songs on others' records they're credited on. albums are +// the artist's own, as the service lists them. +func (p Page) Top(ctx context.Context, artist provider.Artist, albums []provider.Album) (top, appearsOn []provider.Track) { + pool := p.songsBy(ctx, artist, poolSize) + if want := p.topSongs(ctx, artist.Name); len(want) > 0 { + top = p.find(ctx, artist, want, pool) + } + if len(top) == 0 { + if rec, ok := p.Sess.(provider.Recommender); ok && p.Caps.Recommendations { + ts, err := rec.TopTracks(ctx, artist.Name, TopSongs*2) + if err != nil { + slog.Debug("artistpage: top tracks", "artist", artist.Name, "err", err) + } + top = by(artist, ts) + } + } + if len(top) == 0 { + // Nothing knows what's popular: what the service finds first. + top = pool + } + top = distinct(top) + top = top[:min(len(top), TopSongs)] + + own := map[string]bool{} + for _, al := range albums { + own[al.ID] = true + own["title\x00"+match.Simplify(al.Title)] = true + } + shown := map[string]bool{} + for _, t := range top { + shown[t.Ref.ID] = true + } + for _, t := range pool { + if shown[t.Ref.ID] || t.Album.Title == "" || own[t.Album.ID] || own["title\x00"+match.Simplify(t.Album.Title)] { + continue + } + appearsOn = append(appearsOn, t) + } + appearsOn = distinct(appearsOn) + return top, appearsOn[:min(len(appearsOn), maxAppearances)] +} + +// topSongs are an artist's most popular songs, as far as anyone knows. +func (p Page) topSongs(ctx context.Context, name string) []musicgraph.Song { + if p.Graph == nil { + return nil + } + ctx, cancel := context.WithTimeout(ctx, graphWait) + defer cancel() + a, err := p.Graph.Artist(ctx, musicgraph.ArtistRef{Name: name}) + if err != nil { + slog.Debug("artistpage: what's known about an artist", "artist", name, "err", err) + return nil + } + return a.Top +} + +// find looks for songs on the service, in order, until it has +// TopSongs: first among pool, then by searching for a few. +func (p Page) find(ctx context.Context, artist provider.Artist, want []musicgraph.Song, pool []provider.Track) []provider.Track { + found := make([]*provider.Track, len(want)) + var missing []int + for i, s := range want { + if t, ok := best(wanted(s), pool); ok { + found[i] = &t + } else if len(missing) < maxLookups && i < TopSongs*2 { + missing = append(missing, i) + } + } + // Only look further for songs that would make the list. + have := 0 + for i := range found { + if found[i] != nil { + have++ + } + } + if have < TopSongs && len(missing) > 0 { + each(len(missing), func(j int) { + i := missing[j] + t, score, err := match.On(ctx, p.Sess, wanted(want[i])) + if err != nil { + slog.Debug("artistpage: finding a song", "title", want[i].Title, "err", err) + } + if err == nil && score > 0 && credited(artist, t) { + found[i] = &t + } + }) + } + var out []provider.Track + for _, t := range found { + if t != nil { + out = append(out, *t) + } + } + return out +} + +// wanted is a song to look for. +func wanted(s musicgraph.Song) provider.Track { + return provider.Track{Title: s.Title, ISRC: s.ISRC, MBID: s.MBID, Artists: []provider.ArtistCredit{{Name: s.Artist.Name}}} +} + +// best is the song among ts that matches want best. +func best(want provider.Track, ts []provider.Track) (provider.Track, bool) { + var out provider.Track + top := 0.0 + for _, t := range ts { + if score, ok := match.Score(want, t); ok && score > top { + out, top = t, score + } + } + return out, top > 0 +} + +// songsBy searches the service for an artist's songs: those credited to +// them, in the service's order. +func (p Page) songsBy(ctx context.Context, artist provider.Artist, limit int) []provider.Track { + if !p.Caps.CanSearch(provider.KindTrack) { + return nil + } + page, err := p.Sess.Search(ctx, provider.SearchQuery{Text: artist.Name, Kinds: []provider.EntityKind{provider.KindTrack}, Limit: limit}) + if err != nil { + slog.Debug("artistpage: searching for an artist's songs", "artist", artist.Name, "err", err) + return nil + } + return by(artist, page.Tracks) +} + +// by keeps the songs credited to artist. +func by(artist provider.Artist, ts []provider.Track) []provider.Track { + var out []provider.Track + for _, t := range ts { + if credited(artist, t) { + out = append(out, t) + } + } + return out +} + +// credited reports whether artist is credited on t: by ID when both have +// one, else by name. +func credited(artist provider.Artist, t provider.Track) bool { + want := match.Simplify(artist.Name) + return slices.ContainsFunc(t.Artists, func(a provider.ArtistCredit) bool { + if artist.ID != "" && a.ID != "" { + return a.ID == artist.ID + } + return match.Simplify(a.Name) == want + }) +} + +// distinct drops songs with the same title as one before, and the same +// song twice: services list remasters and live takes alongside. +func distinct(ts []provider.Track) []provider.Track { + seen := map[string]bool{} + var out []provider.Track + for _, t := range ts { + title, _ := match.Title(t.Title) + k := title + if len(t.Artists) > 0 { + k = match.Simplify(t.Artists[0].Name) + "\x00" + title + } + if seen[k] || seen["id\x00"+t.Ref.ID] { + continue + } + seen[k], seen["id\x00"+t.Ref.ID] = true, true + out = append(out, t) + } + return out +} + +// Related is an artist like another, or in a genre. +type Related struct { + Name string + // Score is how alike, from 0 to 1; 0 when not known. + Score float64 + // Artist is them on the service, if they're there. + Artist *provider.Artist +} + +// Similar returns artists like artist, most alike first, and songs by +// them, a few from each in turn. +func (p Page) Similar(ctx context.Context, artist provider.Artist) ([]Related, []provider.Track) { + var refs []Related + if p.Graph != nil { + gctx, cancel := context.WithTimeout(ctx, graphWait) + a, err := p.Graph.Artist(gctx, musicgraph.ArtistRef{Name: artist.Name}) + cancel() + if err != nil { + slog.Debug("artistpage: similar artists", "artist", artist.Name, "err", err) + } + for _, s := range a.Similar[:min(len(a.Similar), RelatedArtists)] { + refs = append(refs, Related{Name: s.Artist.Name, Score: s.Score}) + } + } + if len(refs) > 0 { + return p.look(ctx, refs) + } + // Nothing knows who's alike: the service's own recommendations. + rec, ok := p.Sess.(provider.Recommender) + if !ok || !p.Caps.Recommendations || artist.ID == "" { + return []Related{}, nil + } + ts, err := rec.SimilarToArtist(ctx, artist.ID, RelatedArtists*songsEach*2) + if err != nil { + slog.Debug("artistpage: similar to an artist", "artist", artist.Name, "err", err) + return []Related{}, nil + } + out := []Related{} + seen := map[string]bool{match.Simplify(artist.Name): true} + count := map[string]int{} + var songs []provider.Track + for _, t := range ts { + if len(t.Artists) == 0 { + continue + } + c := t.Artists[0] + k := match.Simplify(c.Name) + if count[k] < songsEach && k != match.Simplify(artist.Name) { + count[k]++ + songs = append(songs, t) + } + if !seen[k] && len(out) < RelatedArtists { + seen[k] = true + r := Related{Name: c.Name} + if c.ID != "" { + r.Artist = &provider.Artist{ID: c.ID, Name: c.Name} + } + out = append(out, r) + } + } + return out, songs +} + +// Genre returns a genre's artists, the ones on the service first, and +// songs by them. +func (p Page) Genre(ctx context.Context, tag string) ([]Related, []provider.Track, error) { + if p.Graph == nil { + return nil, nil, provider.ErrNotFound + } + g, err := p.Graph.Genre(ctx, tag) + if err != nil { + return nil, nil, err + } + refs := make([]Related, 0, GenreArtists) + for _, a := range g.Artists[:min(len(g.Artists), GenreArtists)] { + refs = append(refs, Related{Name: a.Name}) + } + rel, songs := p.look(ctx, refs) + // The ones you can open first, in the genre's order. + slices.SortStableFunc(rel, func(a, b Related) int { + switch { + case a.Artist != nil && b.Artist == nil: + return -1 + case a.Artist == nil && b.Artist != nil: + return 1 + } + return 0 + }) + return rel, songs, nil +} + +// look finds artists on the service, and a few songs by each, with one +// search apiece. Songs come a few from each artist in turn, in order. +func (p Page) look(ctx context.Context, refs []Related) ([]Related, []provider.Track) { + var kinds []provider.EntityKind + for _, k := range []provider.EntityKind{provider.KindArtist, provider.KindTrack} { + if p.Caps.CanSearch(k) { + kinds = append(kinds, k) + } + } + songs := make([][]provider.Track, len(refs)) + if len(kinds) > 0 { + each(len(refs), func(i int) { + page, err := p.Sess.Search(ctx, provider.SearchQuery{Text: refs[i].Name, Kinds: kinds, Limit: 10}) + if err != nil { + slog.Debug("artistpage: looking for an artist", "artist", refs[i].Name, "err", err) + return + } + want := match.Simplify(refs[i].Name) + for _, a := range page.Artists { + if match.Simplify(a.Name) == want { + refs[i].Artist = &a + break + } + } + who := provider.Artist{Name: refs[i].Name} + if refs[i].Artist != nil { + who = *refs[i].Artist + } + songs[i] = distinct(by(who, page.Tracks)) + }) + } + var out []provider.Track + for n := range songsEach { + for _, ts := range songs { + if n < len(ts) { + out = append(out, ts[n]) + } + } + } + return refs, out +} + +// Shuffle returns up to n of an artist's songs at random, spread across +// their albums. +func (p Page) Shuffle(ctx context.Context, albums []provider.Album, n int, rnd *rand.Rand) ([]provider.Track, error) { + if len(albums) == 0 || n <= 0 { + return nil, nil + } + albums = slices.Clone(albums) + rnd.Shuffle(len(albums), func(i, j int) { albums[i], albums[j] = albums[j], albums[i] }) + albums = albums[:min(len(albums), (n+1)/2, maxShuffleAlbums)] + lists := make([][]provider.Track, len(albums)) + errs := make([]error, len(albums)) + each(len(albums), func(i int) { + _, lists[i], errs[i] = p.Sess.Album(ctx, albums[i].ID) + }) + var out []provider.Track + for i := range lists { + rnd.Shuffle(len(lists[i]), func(a, b int) { lists[i][a], lists[i][b] = lists[i][b], lists[i][a] }) + } + for k := 0; len(out) < n; k++ { + added := false + for _, ts := range lists { + if k < len(ts) && len(out) < n { + out = append(out, ts[k]) + added = true + } + } + if !added { + break + } + } + if len(out) == 0 { + for _, err := range errs { + if err != nil { + return nil, err + } + } + } + return out, nil +} + +// maxShuffleAlbums bounds the albums a shuffle opens. +const maxShuffleAlbums = 6 + +// Elsewhere finds the artist called name on another service's session: +// nil if they're not there. +func Elsewhere(ctx context.Context, sess provider.Session, caps provider.Capabilities, name string) *provider.Artist { + if !caps.CanSearch(provider.KindArtist) { + return nil + } + page, err := sess.Search(ctx, provider.SearchQuery{Text: name, Kinds: []provider.EntityKind{provider.KindArtist}, Limit: 5}) + if err != nil { + slog.Debug("artistpage: looking for an artist elsewhere", "artist", name, "err", err) + return nil + } + want := match.Simplify(name) + for _, a := range page.Artists { + if match.Simplify(a.Name) == want { + return &a + } + } + return nil +} + +// each runs f for 0..n-1, a few at a time. +func each(n int, f func(i int)) { + sem := make(chan struct{}, parallel) + var wg sync.WaitGroup + for i := range n { + sem <- struct{}{} + wg.Go(func() { + defer func() { <-sem }() + f(i) + }) + } + wg.Wait() +} diff --git a/server/internal/artistpage/artistpage_test.go b/server/internal/artistpage/artistpage_test.go new file mode 100644 index 0000000..74746ec --- /dev/null +++ b/server/internal/artistpage/artistpage_test.go @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package artistpage_test + +import ( + "context" + "math/rand/v2" + "slices" + "testing" + + "github.com/madeofpendletonwool/syncphony/server/internal/artistpage" + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/provider/fake" +) + +// graph knows a little about the fake's artists. +type graph struct { + artists map[string]musicgraph.Artist + genres map[string]musicgraph.Genre +} + +func (g graph) Artist(_ context.Context, a musicgraph.ArtistRef) (musicgraph.Artist, error) { + if out, ok := g.artists[a.Name]; ok { + return out, nil + } + return musicgraph.Artist{}, provider.ErrNotFound +} + +func (g graph) CachedArtist(ctx context.Context, a musicgraph.ArtistRef) (musicgraph.Artist, bool, error) { + out, err := g.Artist(ctx, a) + return out, true, err +} + +func (g graph) Genre(_ context.Context, tag string) (musicgraph.Genre, error) { + if out, ok := g.genres[tag]; ok { + return out, nil + } + return musicgraph.Genre{}, provider.ErrNotFound +} + +func (graph) WarmArtist(...musicgraph.ArtistRef) {} + +func song(title, artist string) musicgraph.Song { + return musicgraph.Song{SongRef: musicgraph.SongRef{Title: title, Artist: musicgraph.ArtistRef{Name: artist}}} +} + +var known = graph{ + artists: map[string]musicgraph.Artist{ + "Null Island": { + // One the fake doesn't have, and one twice (a remaster). + Top: []musicgraph.Song{ + song("Equator", "Null Island"), song("Not In The Library", "Null Island"), + song("Latitude", "Null Island"), song("Equator - 2020 Remaster", "Null Island"), + }, + Similar: []musicgraph.Similar{ + {Artist: musicgraph.ArtistRef{Name: "Sine Language"}, Score: 0.9}, + {Artist: musicgraph.ArtistRef{Name: "Nobody Here"}, Score: 0.5}, + }, + }, + }, + genres: map[string]musicgraph.Genre{ + "test tones": {Name: "test tones", Artists: []musicgraph.ArtistRef{{Name: "Nobody Here"}, {Name: "The Test Patterns"}}}, + }, +} + +func open(t *testing.T, opts fake.Options) (provider.Session, provider.Capabilities) { + t.Helper() + p := fake.New(opts) + creds, account, err := p.Linker().Complete(t.Context(), provider.LinkInput{Fields: map[string]string{"username": fake.Username, "password": fake.Password}}) + if err != nil { + t.Fatal(err) + } + sess, err := p.Open(t.Context(), provider.Link{ID: "l1", Account: account, Credentials: creds}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { sess.Close() }) + return sess, p.Info().Capabilities +} + +// artist finds one of the fake's artists by name. +func artist(t *testing.T, sess provider.Session, name string) (provider.Artist, []provider.Album) { + t.Helper() + page, err := sess.Search(t.Context(), provider.SearchQuery{Text: name, Kinds: []provider.EntityKind{provider.KindArtist}}) + if err != nil || len(page.Artists) == 0 { + t.Fatalf("no %s: %v", name, err) + } + a, albums, err := sess.Artist(t.Context(), page.Artists[0].ID) + if err != nil { + t.Fatal(err) + } + return a, albums +} + +func titles(ts []provider.Track) []string { + var out []string + for _, t := range ts { + out = append(out, t.Title) + } + return out +} + +func TestTopFromGraph(t *testing.T) { + sess, caps := open(t, fake.Options{}) + a, albums := artist(t, sess, "Null Island") + top, appears := artistpage.Page{Sess: sess, Caps: caps, Graph: known}.Top(t.Context(), a, albums) + // In the graph's order, found on the service, each song once. + if want := []string{"Equator", "Latitude"}; !slices.Equal(titles(top), want) { + t.Errorf("top = %v, want %v", titles(top), want) + } + if len(appears) != 0 { + t.Errorf("appears on %v", titles(appears)) + } +} + +func TestTopFromService(t *testing.T) { + sess, caps := open(t, fake.Options{}) + a, albums := artist(t, sess, "Sine Language") + // The graph doesn't know them: the service's top tracks. + top, _ := artistpage.Page{Sess: sess, Caps: caps, Graph: known}.Top(t.Context(), a, albums) + if want := []string{"Concert Pitch", "Fundamental"}; !slices.Equal(titles(top), want) { + t.Errorf("top = %v, want %v", titles(top), want) + } + // A service that can't say: what its search finds first. + sess, caps = open(t, fake.Options{NoRecommendations: true}) + a, albums = artist(t, sess, "Sine Language") + top, _ = artistpage.Page{Sess: sess, Caps: caps}.Top(t.Context(), a, albums) + if len(top) != 6 || top[0].Title != "Concert Pitch" { + t.Errorf("top = %v", titles(top)) + } +} + +func TestAppearsOn(t *testing.T) { + sess, caps := open(t, fake.Options{}) + a, albums := artist(t, sess, "Null Island") + // Their songs on an album that isn't theirs, but not their top songs. + _, appears := artistpage.Page{Sess: sess, Caps: caps, Graph: known}.Top(t.Context(), a, albums[:1]) + if want := []string{"Greenwich", "Gulf of Guinea"}; !slices.Equal(titles(appears), want) { + t.Errorf("appears on %v, want %v", titles(appears), want) + } +} + +func TestSimilar(t *testing.T) { + sess, caps := open(t, fake.Options{}) + a, _ := artist(t, sess, "Null Island") + rel, songs := artistpage.Page{Sess: sess, Caps: caps, Graph: known}.Similar(t.Context(), a) + if len(rel) != 2 || rel[0].Name != "Sine Language" || rel[0].Artist == nil || rel[0].Score != 0.9 || rel[1].Artist != nil { + t.Fatalf("related: %+v", rel) + } + if want := []string{"Concert Pitch", "Tuning Fork"}; !slices.Equal(titles(songs), want) { + t.Errorf("songs = %v, want %v", titles(songs), want) + } + + // Without a graph, the service's own recommendations. + rel, songs = artistpage.Page{Sess: sess, Caps: caps}.Similar(t.Context(), a) + if len(rel) != 2 || rel[0].Name != "Sine Language" || rel[0].Artist == nil || len(songs) != 4 { + t.Errorf("from the service: %+v, %v", rel, titles(songs)) + } + // Neither: nothing. + sess, caps = open(t, fake.Options{NoRecommendations: true}) + if rel, songs := (artistpage.Page{Sess: sess, Caps: caps}).Similar(t.Context(), a); len(rel) != 0 || len(songs) != 0 { + t.Errorf("nothing to go on: %+v, %v", rel, songs) + } +} + +func TestGenre(t *testing.T) { + sess, caps := open(t, fake.Options{}) + p := artistpage.Page{Sess: sess, Caps: caps, Graph: known} + rel, songs, err := p.Genre(t.Context(), "test tones") + if err != nil { + t.Fatal(err) + } + // The ones on the service first. + if len(rel) != 2 || rel[0].Name != "The Test Patterns" || rel[0].Artist == nil || rel[1].Artist != nil { + t.Errorf("artists: %+v", rel) + } + if len(songs) != 2 { + t.Errorf("songs = %v", titles(songs)) + } + if _, _, err := p.Genre(t.Context(), "polka"); err == nil { + t.Error("unknown genre found") + } +} + +func TestShuffle(t *testing.T) { + sess, caps := open(t, fake.Options{}) + _, albums := artist(t, sess, "Null Island") + p := artistpage.Page{Sess: sess, Caps: caps} + ts, err := p.Shuffle(t.Context(), albums, 4, seeded()) + if err != nil || len(ts) != 4 { + t.Fatalf("Shuffle = %v, %v", titles(ts), err) + } + // Spread across both albums. + on := map[string]int{} + for _, tr := range ts { + on[tr.Album.Title]++ + } + if on["Zero Zero"] != 2 || on["Prime Meridian"] != 2 { + t.Errorf("albums: %v", on) + } + if ts, _ := p.Shuffle(t.Context(), albums, 100, seeded()); len(ts) != 6 { + t.Errorf("more than they have: %d", len(ts)) + } +} + +func TestElsewhere(t *testing.T) { + sess, caps := open(t, fake.Options{}) + if a := artistpage.Elsewhere(t.Context(), sess, caps, "null island"); a == nil || a.Name != "Null Island" { + t.Errorf("Elsewhere = %+v", a) + } + if a := artistpage.Elsewhere(t.Context(), sess, caps, "Null"); a != nil { + t.Errorf("partial name found %+v", a) + } +} + +// seeded is the same "random" order every run. +func seeded() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) } //nolint:gosec // a test's fixed order diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go index e4e090c..a73ae46 100644 --- a/server/internal/httpapi/api.gen.go +++ b/server/internal/httpapi/api.gen.go @@ -18,6 +18,51 @@ import ( "github.com/oapi-codegen/runtime" ) +// Defines values for AlbumKind. +const ( + AlbumKindAlbum AlbumKind = "album" + AlbumKindCompilation AlbumKind = "compilation" + AlbumKindEp AlbumKind = "ep" + AlbumKindLive AlbumKind = "live" + AlbumKindSingle AlbumKind = "single" +) + +// Valid indicates whether the value is a known member of the AlbumKind enum. +func (e AlbumKind) Valid() bool { + switch e { + case AlbumKindAlbum: + return true + case AlbumKindCompilation: + return true + case AlbumKindEp: + return true + case AlbumKindLive: + return true + case AlbumKindSingle: + return true + default: + return false + } +} + +// Defines values for ArtistFactKind. +const ( + ArtistFactKindBegan ArtistFactKind = "began" + ArtistFactKindEnded ArtistFactKind = "ended" +) + +// Valid indicates whether the value is a known member of the ArtistFactKind enum. +func (e ArtistFactKind) Valid() bool { + switch e { + case ArtistFactKindBegan: + return true + case ArtistFactKindEnded: + return true + default: + return false + } +} + // Defines values for DisplayPairingStatusStatus. const ( Paired DisplayPairingStatusStatus = "paired" @@ -71,28 +116,28 @@ func (e HealthStatus) Valid() bool { // Defines values for LinerNotesFactKind. const ( - Cover LinerNotesFactKind = "cover" - FirstReleased LinerNotesFactKind = "first_released" - Live LinerNotesFactKind = "live" - Origin LinerNotesFactKind = "origin" - SampledBy LinerNotesFactKind = "sampled_by" - Samples LinerNotesFactKind = "samples" + LinerNotesFactKindCover LinerNotesFactKind = "cover" + LinerNotesFactKindFirstReleased LinerNotesFactKind = "first_released" + LinerNotesFactKindLive LinerNotesFactKind = "live" + LinerNotesFactKindOrigin LinerNotesFactKind = "origin" + LinerNotesFactKindSampledBy LinerNotesFactKind = "sampled_by" + LinerNotesFactKindSamples LinerNotesFactKind = "samples" ) // Valid indicates whether the value is a known member of the LinerNotesFactKind enum. func (e LinerNotesFactKind) Valid() bool { switch e { - case Cover: + case LinerNotesFactKindCover: return true - case FirstReleased: + case LinerNotesFactKindFirstReleased: return true - case Live: + case LinerNotesFactKindLive: return true - case Origin: + case LinerNotesFactKindOrigin: return true - case SampledBy: + case LinerNotesFactKindSampledBy: return true - case Samples: + case LinerNotesFactKindSamples: return true default: return false @@ -302,22 +347,22 @@ func (e PlayerReportEvent) Valid() bool { // Defines values for ProviderInfoCapabilitiesSearch. const ( - Album ProviderInfoCapabilitiesSearch = "album" - Artist ProviderInfoCapabilitiesSearch = "artist" - Playlist ProviderInfoCapabilitiesSearch = "playlist" - Track ProviderInfoCapabilitiesSearch = "track" + ProviderInfoCapabilitiesSearchAlbum ProviderInfoCapabilitiesSearch = "album" + ProviderInfoCapabilitiesSearchArtist ProviderInfoCapabilitiesSearch = "artist" + ProviderInfoCapabilitiesSearchPlaylist ProviderInfoCapabilitiesSearch = "playlist" + ProviderInfoCapabilitiesSearchTrack ProviderInfoCapabilitiesSearch = "track" ) // Valid indicates whether the value is a known member of the ProviderInfoCapabilitiesSearch enum. func (e ProviderInfoCapabilitiesSearch) Valid() bool { switch e { - case Album: + case ProviderInfoCapabilitiesSearchAlbum: return true - case Artist: + case ProviderInfoCapabilitiesSearchArtist: return true - case Playlist: + case ProviderInfoCapabilitiesSearchPlaylist: return true - case Track: + case ProviderInfoCapabilitiesSearchTrack: return true default: return false @@ -781,6 +826,21 @@ type AddToQueueRequest struct { WarnDuplicates *bool `json:"warnDuplicates,omitempty"` } +// AlbumAbout defines model for AlbumAbout. +type AlbumAbout struct { + // About A short summary from Wikipedia. + About *string `json:"about,omitempty"` + AboutUrl *string `json:"aboutUrl,omitempty"` + + // FirstReleased YYYY, YYYY-MM or YYYY-MM-DD. + FirstReleased *string `json:"firstReleased,omitempty"` + Genres []string `json:"genres"` + + // Kind What the service says it is. Services that can't tell an EP from a single call both singles. + Kind *AlbumKind `json:"kind,omitempty"` + Labels []string `json:"labels"` +} + // AlbumCredit defines model for AlbumCredit. type AlbumCredit struct { Id *string `json:"id,omitempty"` @@ -797,14 +857,44 @@ type AlbumDetail struct { Tracks []TrackResult `json:"tracks"` } +// AlbumKind What the service says it is. Services that can't tell an EP from a single call both singles. +type AlbumKind string + // AlbumResult defines model for AlbumResult. type AlbumResult struct { - Artists []ArtistCredit `json:"artists"` - Artwork *string `json:"artwork,omitempty"` - Id string `json:"id"` - Title string `json:"title"` - TrackCount *int `json:"trackCount,omitempty"` - Year *int `json:"year,omitempty"` + Artists []ArtistCredit `json:"artists"` + Artwork *string `json:"artwork,omitempty"` + Id string `json:"id"` + + // Kind What the service says it is. Services that can't tell an EP from a single call both singles. + Kind *AlbumKind `json:"kind,omitempty"` + Title string `json:"title"` + TrackCount *int `json:"trackCount,omitempty"` + Year *int `json:"year,omitempty"` +} + +// ArtistAbout defines model for ArtistAbout. +type ArtistAbout struct { + // About A one-liner, like "Group from Seattle". + About *string `json:"about,omitempty"` + + // AlbumKinds The kind of each of the artist's albums the service doesn't say, by album ID. + AlbumKinds map[string]AlbumKind `json:"albumKinds"` + + // Bio A short summary from Wikipedia. + Bio *string `json:"bio,omitempty"` + + // BioUrl The Wikipedia article. + BioUrl *string `json:"bioUrl,omitempty"` + Facts []ArtistFact `json:"facts"` + + // Genres Most fitting first. + Genres []string `json:"genres"` + Mbid *string `json:"mbid,omitempty"` + + // Members A group's members, current first. + Members []ArtistMember `json:"members"` + Name string `json:"name"` } // ArtistCount defines model for ArtistCount. @@ -828,6 +918,38 @@ type ArtistDetail struct { Provider string `json:"provider"` } +// ArtistElsewhere defines model for ArtistElsewhere. +type ArtistElsewhere struct { + AccountLabel string `json:"accountLabel"` + Artist ArtistResult `json:"artist"` + LinkId string `json:"linkId"` + Provider string `json:"provider"` +} + +// ArtistFact defines model for ArtistFact. +type ArtistFact struct { + Kind ArtistFactKind `json:"kind"` + + // Text "Formed in Leeds in 1994", "Split up in 2012"... + Text string `json:"text"` +} + +// ArtistFactKind defines model for ArtistFact.Kind. +type ArtistFactKind string + +// ArtistMember defines model for ArtistMember. +type ArtistMember struct { + Current bool `json:"current"` + + // From The year they joined. + From *int `json:"from,omitempty"` + Name string `json:"name"` + Roles []string `json:"roles"` + + // To The year they left. + To *int `json:"to,omitempty"` +} + // ArtistResult defines model for ArtistResult. type ArtistResult struct { Artwork *string `json:"artwork,omitempty"` @@ -835,6 +957,15 @@ type ArtistResult struct { Name string `json:"name"` } +// ArtistTracks defines model for ArtistTracks. +type ArtistTracks struct { + // AppearsOn Songs on others' records they're credited on. + AppearsOn []TrackResult `json:"appearsOn"` + + // Top Most popular first. + Top []TrackResult `json:"top"` +} + // AutopilotPick Set when autopilot queued the song, because the queue ran dry. // Label it "Autopilot", not as anyone's. It plays after every // member's song, takes nobody's turn, and anyone may remove it. @@ -1051,6 +1182,14 @@ type FinishCeremony struct { Name *string `json:"name,omitempty"` } +// GenreDetail defines model for GenreDetail. +type GenreDetail struct { + // Artists Those on the link first. + Artists []RelatedArtist `json:"artists"` + Name string `json:"name"` + Tracks []TrackResult `json:"tracks"` +} + // Guest defines model for Guest. type Guest struct { ExpiresAt time.Time `json:"expiresAt"` @@ -1733,6 +1872,21 @@ type ReactionRequest struct { Emoji ReactionEmoji `json:"emoji"` } +// RelatedArtist defines model for RelatedArtist. +type RelatedArtist struct { + Artist *ArtistResult `json:"artist,omitempty"` + Name string `json:"name"` + + // Score How alike, from 0 to 1, when known. + Score *float32 `json:"score,omitempty"` +} + +// RelatedMusic defines model for RelatedMusic. +type RelatedMusic struct { + Artists []RelatedArtist `json:"artists"` + Tracks []TrackResult `json:"tracks"` +} + // RelinkRequest defines model for RelinkRequest. type RelinkRequest struct { Fields map[string]string `json:"fields"` @@ -2515,6 +2669,11 @@ type CompleteOAuthLinkParams struct { Error *string `form:"error,omitempty" json:"error,omitempty"` } +// GetArtistShuffleParams defines parameters for GetArtistShuffle. +type GetArtistShuffleParams struct { + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` +} + // GetLinkArtworkParams defines parameters for GetLinkArtwork. type GetLinkArtworkParams struct { Ref string `form:"ref" json:"ref"` @@ -2523,6 +2682,11 @@ type GetLinkArtworkParams struct { Size *int `form:"size,omitempty" json:"size,omitempty"` } +// GetGenreParams defines parameters for GetGenre. +type GetGenreParams struct { + Name string `form:"name" json:"name"` +} + // ListPlaylistsParams defines parameters for ListPlaylists. type ListPlaylistsParams struct { // Cursor The previous page's `next`. @@ -2545,6 +2709,12 @@ type GetRandomTracksParams struct { Limit *int `form:"limit,omitempty" json:"limit,omitempty"` } +// GetRoomArtistPlaysParams defines parameters for GetRoomArtistPlays. +type GetRoomArtistPlaysParams struct { + Name string `form:"name" json:"name"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` +} + // GetHistoryParams defines parameters for GetHistory. type GetHistoryParams struct { Limit *int `form:"limit,omitempty" json:"limit,omitempty"` @@ -2842,15 +3012,36 @@ type ServerInterface interface { // GetAlbum An album and its tracks, through one of your links or a shared one // (GET /links/{id}/albums/{albumId}) GetAlbum(w http.ResponseWriter, r *http.Request, id string, albumId string) + // GetAlbumAbout What's known about an album + // (GET /links/{id}/albums/{albumId}/about) + GetAlbumAbout(w http.ResponseWriter, r *http.Request, id string, albumId string) // GetArtist An artist and their albums, through one of your links or a shared one // (GET /links/{id}/artists/{artistId}) GetArtist(w http.ResponseWriter, r *http.Request, id string, artistId string) + // GetArtistAbout What's known about an artist + // (GET /links/{id}/artists/{artistId}/about) + GetArtistAbout(w http.ResponseWriter, r *http.Request, id string, artistId string) + // GetArtistElsewhere The same artist on your other services + // (GET /links/{id}/artists/{artistId}/elsewhere) + GetArtistElsewhere(w http.ResponseWriter, r *http.Request, id string, artistId string) + // GetArtistRelated Artists like an artist, and songs by them + // (GET /links/{id}/artists/{artistId}/related) + GetArtistRelated(w http.ResponseWriter, r *http.Request, id string, artistId string) + // GetArtistShuffle Songs by an artist at random + // (GET /links/{id}/artists/{artistId}/shuffle) + GetArtistShuffle(w http.ResponseWriter, r *http.Request, id string, artistId string, params GetArtistShuffleParams) + // GetArtistTracks An artist's top songs and appearances + // (GET /links/{id}/artists/{artistId}/tracks) + GetArtistTracks(w http.ResponseWriter, r *http.Request, id string, artistId string) // GetLinkArtwork An image from one of your links or a shared one // (GET /links/{id}/artwork) GetLinkArtwork(w http.ResponseWriter, r *http.Request, id string, params GetLinkArtworkParams) // GetCollection Shortcuts into the library of one of your links or a shared one // (GET /links/{id}/collection) GetCollection(w http.ResponseWriter, r *http.Request, id string) + // GetGenre A genre's artists, and songs by them + // (GET /links/{id}/genre) + GetGenre(w http.ResponseWriter, r *http.Request, id string, params GetGenreParams) // ListPlaylists The playlists of one of your links or a shared one // (GET /links/{id}/playlists) ListPlaylists(w http.ResponseWriter, r *http.Request, id string, params ListPlaylistsParams) @@ -2956,6 +3147,9 @@ type ServerInterface interface { // JoinRoomAsAdmin Join a room you can't otherwise open (admins) // (POST /rooms/{roomId}/admin-join) JoinRoomAsAdmin(w http.ResponseWriter, r *http.Request, roomId RoomId) + // GetRoomArtistPlays An artist's songs the room played most + // (GET /rooms/{roomId}/artist-plays) + GetRoomArtistPlays(w http.ResponseWriter, r *http.Request, roomId RoomId, params GetRoomArtistPlaysParams) // ListDisplays The room's paired displays // (GET /rooms/{roomId}/displays) ListDisplays(w http.ResponseWriter, r *http.Request, roomId RoomId) @@ -3781,6 +3975,41 @@ func (siw *ServerInterfaceWrapper) GetAlbum(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } +// GetAlbumAbout operation middleware +func (siw *ServerInterfaceWrapper) GetAlbumAbout(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "albumId" ------------- + var albumId string + + err = runtime.BindStyledParameterWithOptions("simple", "albumId", r.PathValue("albumId"), &albumId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "albumId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetAlbumAbout(w, r, id, albumId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetArtist operation middleware func (siw *ServerInterfaceWrapper) GetArtist(w http.ResponseWriter, r *http.Request) { @@ -3816,8 +4045,8 @@ func (siw *ServerInterfaceWrapper) GetArtist(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } -// GetLinkArtwork operation middleware -func (siw *ServerInterfaceWrapper) GetLinkArtwork(w http.ResponseWriter, r *http.Request) { +// GetArtistAbout operation middleware +func (siw *ServerInterfaceWrapper) GetArtistAbout(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3831,37 +4060,17 @@ func (siw *ServerInterfaceWrapper) GetLinkArtwork(w http.ResponseWriter, r *http return } - // Parameter object where we will unmarshal all parameters from the context - var params GetLinkArtworkParams - - // ------------- Required query parameter "ref" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, true, "ref", r.URL.Query(), ¶ms.Ref, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "ref"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ref", Err: err}) - } - return - } - - // ------------- Optional query parameter "size" ------------- + // ------------- Path parameter "artistId" ------------- + var artistId string - err = runtime.BindQueryParameterWithOptions("form", true, false, "size", r.URL.Query(), ¶ms.Size, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "artistId", r.PathValue("artistId"), &artistId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) 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}) - } + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "artistId", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetLinkArtwork(w, r, id, params) + siw.Handler.GetArtistAbout(w, r, id, artistId) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3871,8 +4080,8 @@ func (siw *ServerInterfaceWrapper) GetLinkArtwork(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// GetCollection operation middleware -func (siw *ServerInterfaceWrapper) GetCollection(w http.ResponseWriter, r *http.Request) { +// GetArtistElsewhere operation middleware +func (siw *ServerInterfaceWrapper) GetArtistElsewhere(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3886,8 +4095,17 @@ func (siw *ServerInterfaceWrapper) GetCollection(w http.ResponseWriter, r *http. return } + // ------------- Path parameter "artistId" ------------- + var artistId string + + err = runtime.BindStyledParameterWithOptions("simple", "artistId", r.PathValue("artistId"), &artistId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "artistId", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetCollection(w, r, id) + siw.Handler.GetArtistElsewhere(w, r, id, artistId) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3897,8 +4115,8 @@ func (siw *ServerInterfaceWrapper) GetCollection(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// ListPlaylists operation middleware -func (siw *ServerInterfaceWrapper) ListPlaylists(w http.ResponseWriter, r *http.Request) { +// GetArtistRelated operation middleware +func (siw *ServerInterfaceWrapper) GetArtistRelated(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3912,24 +4130,17 @@ func (siw *ServerInterfaceWrapper) ListPlaylists(w http.ResponseWriter, r *http. return } - // Parameter object where we will unmarshal all parameters from the context - var params ListPlaylistsParams - - // ------------- Optional query parameter "cursor" ------------- + // ------------- Path parameter "artistId" ------------- + var artistId string - err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "artistId", r.PathValue("artistId"), &artistId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) - } + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "artistId", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListPlaylists(w, r, id, params) + siw.Handler.GetArtistRelated(w, r, id, artistId) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3939,8 +4150,8 @@ func (siw *ServerInterfaceWrapper) ListPlaylists(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// GetPlaylistTracks operation middleware -func (siw *ServerInterfaceWrapper) GetPlaylistTracks(w http.ResponseWriter, r *http.Request) { +// GetArtistShuffle operation middleware +func (siw *ServerInterfaceWrapper) GetArtistShuffle(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3954,33 +4165,33 @@ func (siw *ServerInterfaceWrapper) GetPlaylistTracks(w http.ResponseWriter, r *h return } - // ------------- Path parameter "playlistId" ------------- - var playlistId string + // ------------- Path parameter "artistId" ------------- + var artistId string - err = runtime.BindStyledParameterWithOptions("simple", "playlistId", r.PathValue("playlistId"), &playlistId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + err = runtime.BindStyledParameterWithOptions("simple", "artistId", r.PathValue("artistId"), &artistId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "playlistId", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "artistId", Err: err}) return } // Parameter object where we will unmarshal all parameters from the context - var params GetPlaylistTracksParams + var params GetArtistShuffleParams - // ------------- Optional query parameter "cursor" ------------- + // ------------- Optional query parameter "limit" ------------- - err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) if err != nil { var requiredError *runtime.RequiredParameterError if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) } return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetPlaylistTracks(w, r, id, playlistId, params) + siw.Handler.GetArtistShuffle(w, r, id, artistId, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3990,8 +4201,8 @@ func (siw *ServerInterfaceWrapper) GetPlaylistTracks(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } -// GetTrackLyrics operation middleware -func (siw *ServerInterfaceWrapper) GetTrackLyrics(w http.ResponseWriter, r *http.Request) { +// GetArtistTracks operation middleware +func (siw *ServerInterfaceWrapper) GetArtistTracks(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -4005,17 +4216,17 @@ func (siw *ServerInterfaceWrapper) GetTrackLyrics(w http.ResponseWriter, r *http return } - // ------------- Path parameter "trackId" ------------- - var trackId string + // ------------- Path parameter "artistId" ------------- + var artistId string - err = runtime.BindStyledParameterWithOptions("simple", "trackId", r.PathValue("trackId"), &trackId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + err = runtime.BindStyledParameterWithOptions("simple", "artistId", r.PathValue("artistId"), &artistId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "trackId", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "artistId", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetTrackLyrics(w, r, id, trackId) + siw.Handler.GetArtistTracks(w, r, id, artistId) })) for _, middleware := range siw.HandlerMiddlewares { @@ -4025,39 +4236,52 @@ func (siw *ServerInterfaceWrapper) GetTrackLyrics(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// GetMe operation middleware -func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) { +// GetLinkArtwork operation middleware +func (siw *ServerInterfaceWrapper) GetLinkArtwork(w http.ResponseWriter, r *http.Request) { - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetMe(w, r) - })) + var err error + _ = err - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } + // ------------- Path parameter "id" ------------- + var id string - handler.ServeHTTP(w, r) -} + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } -// UpdateMe operation middleware -func (siw *ServerInterfaceWrapper) UpdateMe(w http.ResponseWriter, r *http.Request) { + // Parameter object where we will unmarshal all parameters from the context + var params GetLinkArtworkParams - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UpdateMe(w, r) - })) + // ------------- Required query parameter "ref" ------------- - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) + err = runtime.BindQueryParameterWithOptions("form", true, true, "ref", r.URL.Query(), ¶ms.Ref, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "ref"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ref", Err: err}) + } + return } - handler.ServeHTTP(w, r) -} + // ------------- Optional query parameter "size" ------------- -// UploadAvatar operation middleware -func (siw *ServerInterfaceWrapper) UploadAvatar(w http.ResponseWriter, r *http.Request) { + 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.UploadAvatar(w, r) + siw.Handler.GetLinkArtwork(w, r, id, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -4067,9 +4291,247 @@ func (siw *ServerInterfaceWrapper) UploadAvatar(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// DeleteMe operation middleware -func (siw *ServerInterfaceWrapper) DeleteMe(w http.ResponseWriter, r *http.Request) { - +// GetCollection operation middleware +func (siw *ServerInterfaceWrapper) GetCollection(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetCollection(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetGenre operation middleware +func (siw *ServerInterfaceWrapper) GetGenre(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetGenreParams + + // ------------- Required query parameter "name" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "name", r.URL.Query(), ¶ms.Name, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "name"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "name", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetGenre(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListPlaylists operation middleware +func (siw *ServerInterfaceWrapper) ListPlaylists(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params ListPlaylistsParams + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListPlaylists(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetPlaylistTracks operation middleware +func (siw *ServerInterfaceWrapper) GetPlaylistTracks(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "playlistId" ------------- + var playlistId string + + err = runtime.BindStyledParameterWithOptions("simple", "playlistId", r.PathValue("playlistId"), &playlistId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "playlistId", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetPlaylistTracksParams + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetPlaylistTracks(w, r, id, playlistId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetTrackLyrics operation middleware +func (siw *ServerInterfaceWrapper) GetTrackLyrics(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "trackId" ------------- + var trackId string + + err = runtime.BindStyledParameterWithOptions("simple", "trackId", r.PathValue("trackId"), &trackId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "trackId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetTrackLyrics(w, r, id, trackId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetMe operation middleware +func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetMe(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateMe operation middleware +func (siw *ServerInterfaceWrapper) UpdateMe(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateMe(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UploadAvatar operation middleware +func (siw *ServerInterfaceWrapper) UploadAvatar(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UploadAvatar(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DeleteMe operation middleware +func (siw *ServerInterfaceWrapper) DeleteMe(w http.ResponseWriter, r *http.Request) { + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.DeleteMe(w, r) })) @@ -4660,6 +5122,61 @@ func (siw *ServerInterfaceWrapper) JoinRoomAsAdmin(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } +// GetRoomArtistPlays operation middleware +func (siw *ServerInterfaceWrapper) GetRoomArtistPlays(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "roomId" ------------- + var roomId RoomId + + err = runtime.BindStyledParameterWithOptions("simple", "roomId", r.PathValue("roomId"), &roomId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roomId", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetRoomArtistPlaysParams + + // ------------- Required query parameter "name" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "name", r.URL.Query(), ¶ms.Name, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "name"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "name", Err: err}) + } + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetRoomArtistPlays(w, r, roomId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListDisplays operation middleware func (siw *ServerInterfaceWrapper) ListDisplays(w http.ResponseWriter, r *http.Request) { @@ -6627,6 +7144,13 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/albums/{albumId}", wrapper.GetAlbum) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/tracks/{trackId}/lyrics", wrapper.GetTrackLyrics) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/artists/{artistId}", wrapper.GetArtist) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/artists/{artistId}/about", wrapper.GetArtistAbout) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/artists/{artistId}/tracks", wrapper.GetArtistTracks) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/artists/{artistId}/related", wrapper.GetArtistRelated) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/artists/{artistId}/elsewhere", wrapper.GetArtistElsewhere) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/artists/{artistId}/shuffle", wrapper.GetArtistShuffle) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/albums/{albumId}/about", wrapper.GetAlbumAbout) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/genre", wrapper.GetGenre) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/playlists", wrapper.ListPlaylists) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/links/{id}/playlists/{playlistId}/tracks", wrapper.GetPlaylistTracks) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/random-tracks", wrapper.GetRandomTracks) @@ -6638,6 +7162,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/rooms/{roomId}/lane", wrapper.ClearLane) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/stats", wrapper.GetRoomStats) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/sessions", wrapper.ListSessions) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/artist-plays", wrapper.GetRoomArtistPlays) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/history", wrapper.GetHistory) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/suggestions", wrapper.GetSuggestions) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/rooms/{roomId}/queue/{itemId}", wrapper.RemoveQueueItem) @@ -7850,12 +8375,252 @@ func (response UpdateLink200JSONResponse) VisitUpdateLinkResponse(w http.Respons return err } -type UpdateLinkdefaultJSONResponse struct { +type UpdateLinkdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response UpdateLinkdefaultJSONResponse) VisitUpdateLinkResponse(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 RelinkRequestObject struct { + Id string `json:"id"` + Body *RelinkJSONRequestBody +} + +type RelinkResponseObject interface { + VisitRelinkResponse(w http.ResponseWriter) error +} + +type Relink200JSONResponse ServiceLink + +func (response Relink200JSONResponse) VisitRelinkResponse(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 RelinkdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response RelinkdefaultJSONResponse) VisitRelinkResponse(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 GetAlbumRequestObject struct { + Id string `json:"id"` + AlbumId string `json:"albumId"` +} + +type GetAlbumResponseObject interface { + VisitGetAlbumResponse(w http.ResponseWriter) error +} + +type GetAlbum200JSONResponse AlbumDetail + +func (response GetAlbum200JSONResponse) VisitGetAlbumResponse(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 GetAlbumdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetAlbumdefaultJSONResponse) VisitGetAlbumResponse(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 GetAlbumAboutRequestObject struct { + Id string `json:"id"` + AlbumId string `json:"albumId"` +} + +type GetAlbumAboutResponseObject interface { + VisitGetAlbumAboutResponse(w http.ResponseWriter) error +} + +type GetAlbumAbout200JSONResponse AlbumAbout + +func (response GetAlbumAbout200JSONResponse) VisitGetAlbumAboutResponse(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 GetAlbumAboutdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetAlbumAboutdefaultJSONResponse) VisitGetAlbumAboutResponse(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 GetArtistRequestObject struct { + Id string `json:"id"` + ArtistId string `json:"artistId"` +} + +type GetArtistResponseObject interface { + VisitGetArtistResponse(w http.ResponseWriter) error +} + +type GetArtist200JSONResponse ArtistDetail + +func (response GetArtist200JSONResponse) VisitGetArtistResponse(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 GetArtistdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetArtistdefaultJSONResponse) VisitGetArtistResponse(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 GetArtistAboutRequestObject struct { + Id string `json:"id"` + ArtistId string `json:"artistId"` +} + +type GetArtistAboutResponseObject interface { + VisitGetArtistAboutResponse(w http.ResponseWriter) error +} + +type GetArtistAbout200JSONResponse ArtistAbout + +func (response GetArtistAbout200JSONResponse) VisitGetArtistAboutResponse(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 GetArtistAboutdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetArtistAboutdefaultJSONResponse) VisitGetArtistAboutResponse(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 GetArtistElsewhereRequestObject struct { + Id string `json:"id"` + ArtistId string `json:"artistId"` +} + +type GetArtistElsewhereResponseObject interface { + VisitGetArtistElsewhereResponse(w http.ResponseWriter) error +} + +type GetArtistElsewhere200JSONResponse []ArtistElsewhere + +func (response GetArtistElsewhere200JSONResponse) VisitGetArtistElsewhereResponse(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 GetArtistElsewheredefaultJSONResponse struct { Body Error StatusCode int } -func (response UpdateLinkdefaultJSONResponse) VisitUpdateLinkResponse(w http.ResponseWriter) error { +func (response GetArtistElsewheredefaultJSONResponse) VisitGetArtistElsewhereResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { @@ -7867,18 +8632,18 @@ func (response UpdateLinkdefaultJSONResponse) VisitUpdateLinkResponse(w http.Res return err } -type RelinkRequestObject struct { - Id string `json:"id"` - Body *RelinkJSONRequestBody +type GetArtistRelatedRequestObject struct { + Id string `json:"id"` + ArtistId string `json:"artistId"` } -type RelinkResponseObject interface { - VisitRelinkResponse(w http.ResponseWriter) error +type GetArtistRelatedResponseObject interface { + VisitGetArtistRelatedResponse(w http.ResponseWriter) error } -type Relink200JSONResponse ServiceLink +type GetArtistRelated200JSONResponse RelatedMusic -func (response Relink200JSONResponse) VisitRelinkResponse(w http.ResponseWriter) error { +func (response GetArtistRelated200JSONResponse) VisitGetArtistRelatedResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response); err != nil { @@ -7890,12 +8655,12 @@ func (response Relink200JSONResponse) VisitRelinkResponse(w http.ResponseWriter) return err } -type RelinkdefaultJSONResponse struct { +type GetArtistRelateddefaultJSONResponse struct { Body Error StatusCode int } -func (response RelinkdefaultJSONResponse) VisitRelinkResponse(w http.ResponseWriter) error { +func (response GetArtistRelateddefaultJSONResponse) VisitGetArtistRelatedResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { @@ -7907,18 +8672,19 @@ func (response RelinkdefaultJSONResponse) VisitRelinkResponse(w http.ResponseWri return err } -type GetAlbumRequestObject struct { - Id string `json:"id"` - AlbumId string `json:"albumId"` +type GetArtistShuffleRequestObject struct { + Id string `json:"id"` + ArtistId string `json:"artistId"` + Params GetArtistShuffleParams } -type GetAlbumResponseObject interface { - VisitGetAlbumResponse(w http.ResponseWriter) error +type GetArtistShuffleResponseObject interface { + VisitGetArtistShuffleResponse(w http.ResponseWriter) error } -type GetAlbum200JSONResponse AlbumDetail +type GetArtistShuffle200JSONResponse []TrackResult -func (response GetAlbum200JSONResponse) VisitGetAlbumResponse(w http.ResponseWriter) error { +func (response GetArtistShuffle200JSONResponse) VisitGetArtistShuffleResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response); err != nil { @@ -7930,12 +8696,12 @@ func (response GetAlbum200JSONResponse) VisitGetAlbumResponse(w http.ResponseWri return err } -type GetAlbumdefaultJSONResponse struct { +type GetArtistShuffledefaultJSONResponse struct { Body Error StatusCode int } -func (response GetAlbumdefaultJSONResponse) VisitGetAlbumResponse(w http.ResponseWriter) error { +func (response GetArtistShuffledefaultJSONResponse) VisitGetArtistShuffleResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { @@ -7947,18 +8713,18 @@ func (response GetAlbumdefaultJSONResponse) VisitGetAlbumResponse(w http.Respons return err } -type GetArtistRequestObject struct { +type GetArtistTracksRequestObject struct { Id string `json:"id"` ArtistId string `json:"artistId"` } -type GetArtistResponseObject interface { - VisitGetArtistResponse(w http.ResponseWriter) error +type GetArtistTracksResponseObject interface { + VisitGetArtistTracksResponse(w http.ResponseWriter) error } -type GetArtist200JSONResponse ArtistDetail +type GetArtistTracks200JSONResponse ArtistTracks -func (response GetArtist200JSONResponse) VisitGetArtistResponse(w http.ResponseWriter) error { +func (response GetArtistTracks200JSONResponse) VisitGetArtistTracksResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response); err != nil { @@ -7970,12 +8736,12 @@ func (response GetArtist200JSONResponse) VisitGetArtistResponse(w http.ResponseW return err } -type GetArtistdefaultJSONResponse struct { +type GetArtistTracksdefaultJSONResponse struct { Body Error StatusCode int } -func (response GetArtistdefaultJSONResponse) VisitGetArtistResponse(w http.ResponseWriter) error { +func (response GetArtistTracksdefaultJSONResponse) VisitGetArtistTracksResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { @@ -8073,6 +8839,46 @@ func (response GetCollectiondefaultJSONResponse) VisitGetCollectionResponse(w ht return err } +type GetGenreRequestObject struct { + Id string `json:"id"` + Params GetGenreParams +} + +type GetGenreResponseObject interface { + VisitGetGenreResponse(w http.ResponseWriter) error +} + +type GetGenre200JSONResponse GenreDetail + +func (response GetGenre200JSONResponse) VisitGetGenreResponse(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 GetGenredefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetGenredefaultJSONResponse) VisitGetGenreResponse(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 ListPlaylistsRequestObject struct { Id string `json:"id"` Params ListPlaylistsParams @@ -9403,6 +10209,46 @@ func (response JoinRoomAsAdmindefaultJSONResponse) VisitJoinRoomAsAdminResponse( return err } +type GetRoomArtistPlaysRequestObject struct { + RoomId RoomId `json:"roomId"` + Params GetRoomArtistPlaysParams +} + +type GetRoomArtistPlaysResponseObject interface { + VisitGetRoomArtistPlaysResponse(w http.ResponseWriter) error +} + +type GetRoomArtistPlays200JSONResponse []TrackCount + +func (response GetRoomArtistPlays200JSONResponse) VisitGetRoomArtistPlaysResponse(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 GetRoomArtistPlaysdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetRoomArtistPlaysdefaultJSONResponse) VisitGetRoomArtistPlaysResponse(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 ListDisplaysRequestObject struct { RoomId RoomId `json:"roomId"` } @@ -11616,15 +12462,36 @@ type StrictServerInterface interface { // GetAlbum An album and its tracks, through one of your links or a shared one // (GET /links/{id}/albums/{albumId}) GetAlbum(ctx context.Context, request GetAlbumRequestObject) (GetAlbumResponseObject, error) + // GetAlbumAbout What's known about an album + // (GET /links/{id}/albums/{albumId}/about) + GetAlbumAbout(ctx context.Context, request GetAlbumAboutRequestObject) (GetAlbumAboutResponseObject, error) // GetArtist An artist and their albums, through one of your links or a shared one // (GET /links/{id}/artists/{artistId}) GetArtist(ctx context.Context, request GetArtistRequestObject) (GetArtistResponseObject, error) + // GetArtistAbout What's known about an artist + // (GET /links/{id}/artists/{artistId}/about) + GetArtistAbout(ctx context.Context, request GetArtistAboutRequestObject) (GetArtistAboutResponseObject, error) + // GetArtistElsewhere The same artist on your other services + // (GET /links/{id}/artists/{artistId}/elsewhere) + GetArtistElsewhere(ctx context.Context, request GetArtistElsewhereRequestObject) (GetArtistElsewhereResponseObject, error) + // GetArtistRelated Artists like an artist, and songs by them + // (GET /links/{id}/artists/{artistId}/related) + GetArtistRelated(ctx context.Context, request GetArtistRelatedRequestObject) (GetArtistRelatedResponseObject, error) + // GetArtistShuffle Songs by an artist at random + // (GET /links/{id}/artists/{artistId}/shuffle) + GetArtistShuffle(ctx context.Context, request GetArtistShuffleRequestObject) (GetArtistShuffleResponseObject, error) + // GetArtistTracks An artist's top songs and appearances + // (GET /links/{id}/artists/{artistId}/tracks) + GetArtistTracks(ctx context.Context, request GetArtistTracksRequestObject) (GetArtistTracksResponseObject, error) // GetLinkArtwork An image from one of your links or a shared one // (GET /links/{id}/artwork) GetLinkArtwork(ctx context.Context, request GetLinkArtworkRequestObject) (GetLinkArtworkResponseObject, error) // GetCollection Shortcuts into the library of one of your links or a shared one // (GET /links/{id}/collection) GetCollection(ctx context.Context, request GetCollectionRequestObject) (GetCollectionResponseObject, error) + // GetGenre A genre's artists, and songs by them + // (GET /links/{id}/genre) + GetGenre(ctx context.Context, request GetGenreRequestObject) (GetGenreResponseObject, error) // ListPlaylists The playlists of one of your links or a shared one // (GET /links/{id}/playlists) ListPlaylists(ctx context.Context, request ListPlaylistsRequestObject) (ListPlaylistsResponseObject, error) @@ -11730,6 +12597,9 @@ type StrictServerInterface interface { // JoinRoomAsAdmin Join a room you can't otherwise open (admins) // (POST /rooms/{roomId}/admin-join) JoinRoomAsAdmin(ctx context.Context, request JoinRoomAsAdminRequestObject) (JoinRoomAsAdminResponseObject, error) + // GetRoomArtistPlays An artist's songs the room played most + // (GET /rooms/{roomId}/artist-plays) + GetRoomArtistPlays(ctx context.Context, request GetRoomArtistPlaysRequestObject) (GetRoomArtistPlaysResponseObject, error) // ListDisplays The room's paired displays // (GET /rooms/{roomId}/displays) ListDisplays(ctx context.Context, request ListDisplaysRequestObject) (ListDisplaysResponseObject, error) @@ -12824,6 +13694,33 @@ func (sh *strictHandler) GetAlbum(w http.ResponseWriter, r *http.Request, id str } } +// GetAlbumAbout operation middleware +func (sh *strictHandler) GetAlbumAbout(w http.ResponseWriter, r *http.Request, id string, albumId string) { + var request GetAlbumAboutRequestObject + + request.Id = id + request.AlbumId = albumId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetAlbumAbout(ctx, request.(GetAlbumAboutRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetAlbumAbout") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetAlbumAboutResponseObject); ok { + if err := validResponse.VisitGetAlbumAboutResponse(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)) + } +} + // GetArtist operation middleware func (sh *strictHandler) GetArtist(w http.ResponseWriter, r *http.Request, id string, artistId string) { var request GetArtistRequestObject @@ -12851,6 +13748,142 @@ func (sh *strictHandler) GetArtist(w http.ResponseWriter, r *http.Request, id st } } +// GetArtistAbout operation middleware +func (sh *strictHandler) GetArtistAbout(w http.ResponseWriter, r *http.Request, id string, artistId string) { + var request GetArtistAboutRequestObject + + request.Id = id + request.ArtistId = artistId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetArtistAbout(ctx, request.(GetArtistAboutRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetArtistAbout") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetArtistAboutResponseObject); ok { + if err := validResponse.VisitGetArtistAboutResponse(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)) + } +} + +// GetArtistElsewhere operation middleware +func (sh *strictHandler) GetArtistElsewhere(w http.ResponseWriter, r *http.Request, id string, artistId string) { + var request GetArtistElsewhereRequestObject + + request.Id = id + request.ArtistId = artistId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetArtistElsewhere(ctx, request.(GetArtistElsewhereRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetArtistElsewhere") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetArtistElsewhereResponseObject); ok { + if err := validResponse.VisitGetArtistElsewhereResponse(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)) + } +} + +// GetArtistRelated operation middleware +func (sh *strictHandler) GetArtistRelated(w http.ResponseWriter, r *http.Request, id string, artistId string) { + var request GetArtistRelatedRequestObject + + request.Id = id + request.ArtistId = artistId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetArtistRelated(ctx, request.(GetArtistRelatedRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetArtistRelated") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetArtistRelatedResponseObject); ok { + if err := validResponse.VisitGetArtistRelatedResponse(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)) + } +} + +// GetArtistShuffle operation middleware +func (sh *strictHandler) GetArtistShuffle(w http.ResponseWriter, r *http.Request, id string, artistId string, params GetArtistShuffleParams) { + var request GetArtistShuffleRequestObject + + request.Id = id + request.ArtistId = artistId + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetArtistShuffle(ctx, request.(GetArtistShuffleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetArtistShuffle") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetArtistShuffleResponseObject); ok { + if err := validResponse.VisitGetArtistShuffleResponse(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)) + } +} + +// GetArtistTracks operation middleware +func (sh *strictHandler) GetArtistTracks(w http.ResponseWriter, r *http.Request, id string, artistId string) { + var request GetArtistTracksRequestObject + + request.Id = id + request.ArtistId = artistId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetArtistTracks(ctx, request.(GetArtistTracksRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetArtistTracks") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetArtistTracksResponseObject); ok { + if err := validResponse.VisitGetArtistTracksResponse(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)) + } +} + // GetLinkArtwork operation middleware func (sh *strictHandler) GetLinkArtwork(w http.ResponseWriter, r *http.Request, id string, params GetLinkArtworkParams) { var request GetLinkArtworkRequestObject @@ -12904,6 +13937,33 @@ func (sh *strictHandler) GetCollection(w http.ResponseWriter, r *http.Request, i } } +// GetGenre operation middleware +func (sh *strictHandler) GetGenre(w http.ResponseWriter, r *http.Request, id string, params GetGenreParams) { + var request GetGenreRequestObject + + request.Id = id + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetGenre(ctx, request.(GetGenreRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetGenre") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetGenreResponseObject); ok { + if err := validResponse.VisitGetGenreResponse(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)) + } +} + // ListPlaylists operation middleware func (sh *strictHandler) ListPlaylists(w http.ResponseWriter, r *http.Request, id string, params ListPlaylistsParams) { var request ListPlaylistsRequestObject @@ -13858,6 +14918,33 @@ func (sh *strictHandler) JoinRoomAsAdmin(w http.ResponseWriter, r *http.Request, } } +// GetRoomArtistPlays operation middleware +func (sh *strictHandler) GetRoomArtistPlays(w http.ResponseWriter, r *http.Request, roomId RoomId, params GetRoomArtistPlaysParams) { + var request GetRoomArtistPlaysRequestObject + + request.RoomId = roomId + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetRoomArtistPlays(ctx, request.(GetRoomArtistPlaysRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetRoomArtistPlays") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetRoomArtistPlaysResponseObject); ok { + if err := validResponse.VisitGetRoomArtistPlaysResponse(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)) + } +} + // ListDisplays operation middleware func (sh *strictHandler) ListDisplays(w http.ResponseWriter, r *http.Request, roomId RoomId) { var request ListDisplaysRequestObject diff --git a/server/internal/httpapi/artistpage.go b/server/internal/httpapi/artistpage.go new file mode 100644 index 0000000..dc294db --- /dev/null +++ b/server/internal/httpapi/artistpage.go @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package httpapi + +import ( + "context" + "errors" + "log/slog" + "math/rand/v2" + "slices" + "strings" + "sync" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/artistpage" + "github.com/madeofpendletonwool/syncphony/server/internal/linernotes" + "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/rooms" + "github.com/madeofpendletonwool/syncphony/server/internal/stats" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// Artist and album pages (MAD-763): what's known about them, beyond what +// their service says. + +// maxGenres is how many genres an artist's page shows. +const maxGenres = 8 + +// minTagWeight is how strongly a tag must apply to an artist to show as a +// genre, when MusicBrainz doesn't list enough. +const minTagWeight = 0.2 + +// notGenres are tags people put on artists that aren't genres. +var notGenres = map[string]bool{ + "seen live": true, "favorites": true, "favourites": true, "favorite": true, "favourite": true, + "my favorite": true, "awesome": true, "love": true, "beautiful": true, "cool": true, +} + +// artistPage opens a link the caller may use and reads one of its artists. +func (s *Server) artistPage(ctx context.Context, linkID, artistID string) (store.ServiceLink, artistpage.Page, provider.Artist, []provider.Album, func(), error) { + l, sess, err := s.openUsable(ctx, linkID) + if err != nil { + return l, artistpage.Page{}, provider.Artist{}, nil, nil, err + } + p, err := s.Links.Provider(l.Provider) + if err != nil { + sess.Close() + return l, artistpage.Page{}, provider.Artist{}, nil, nil, err + } + a, albums, err := sess.Artist(ctx, artistID) + if err != nil { + sess.Close() + return l, artistpage.Page{}, provider.Artist{}, nil, nil, err + } + page := artistpage.Page{Sess: sess, Caps: p.Info().Capabilities} + if s.Graph != nil { + page.Graph = s.Graph + } + return l, page, a, albums, func() { sess.Close() }, nil +} + +// GetArtistAbout returns an artist's bio, facts, members and genres, and +// the kinds of their albums the service doesn't say. +func (s *Server) GetArtistAbout(ctx context.Context, req GetArtistAboutRequestObject) (GetArtistAboutResponseObject, error) { + _, page, a, albums, done, err := s.artistPage(ctx, req.Id, req.ArtistId) + if err != nil { + return nil, err + } + done() + out := ArtistAbout{Name: a.Name, Facts: []ArtistFact{}, Members: []ArtistMember{}, Genres: []string{}, AlbumKinds: map[string]AlbumKind{}} + + var notes linernotes.ArtistNotes + var tags []musicgraph.Tag + var wg sync.WaitGroup + if s.LinerNotes != nil { + wg.Go(func() { + n, err := s.LinerNotes.Artist(ctx, a.Name) + if err != nil && !errors.Is(err, provider.ErrNotFound) { + slog.Warn("artist notes", "artist", a.Name, "err", err) + } + notes = n + }) + } + if page.Graph != nil { + wg.Go(func() { + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + if g, err := page.Graph.Artist(ctx, musicgraph.ArtistRef{Name: a.Name}); err == nil { + tags = g.Tags + } + }) + } + wg.Wait() + + if notes.MBID != "" { + out.Mbid = ¬es.MBID + } + out.About, out.Bio, out.BioUrl = optional(notes.About), optional(notes.Bio), optional(notes.BioURL) + for _, f := range notes.Facts { + out.Facts = append(out.Facts, ArtistFact{Kind: ArtistFactKind(f.Kind), Text: f.Text}) + } + for _, m := range notes.Members { + am := ArtistMember{Name: m.Name, Current: m.Current, Roles: nonNilStrings(m.Roles)} + if m.From > 0 { + am.From = ptr(m.From) + } + if m.To > 0 { + am.To = ptr(m.To) + } + out.Members = append(out.Members, am) + } + out.Genres = genres(notes.Genres, tags) + + // The kinds MusicBrainz knows of the albums the service doesn't say. + kinds := map[string]string{} + for _, r := range notes.Releases { + if t, _ := match.Title(r.Title); t != "" { + if _, ok := kinds[t]; !ok { + kinds[t] = r.Kind + } + } + } + for _, al := range albums { + if al.Kind != "" { + continue + } + if t, _ := match.Title(al.Title); kinds[t] != "" { + out.AlbumKinds[al.ID] = AlbumKind(kinds[t]) + } + } + return GetArtistAbout200JSONResponse(out), nil +} + +// genres are MusicBrainz's curated genres, then the strongest tags others +// put on the artist, each once. +func genres(curated []string, tags []musicgraph.Tag) []string { + out := []string{} + seen := map[string]bool{} + add := func(g string) { + k := match.Simplify(g) + if k == "" || seen[k] || notGenres[k] || len(out) >= maxGenres { + return + } + seen[k] = true + out = append(out, strings.ToLower(strings.TrimSpace(g))) + } + for _, g := range curated { + add(g) + } + for _, t := range tags { + if t.Weight >= minTagWeight { + add(t.Name) + } + } + return out +} + +// GetArtistTracks returns an artist's top songs and the songs they're +// featured on. +func (s *Server) GetArtistTracks(ctx context.Context, req GetArtistTracksRequestObject) (GetArtistTracksResponseObject, error) { + _, page, a, albums, done, err := s.artistPage(ctx, req.Id, req.ArtistId) + if err != nil { + return nil, err + } + defer done() + top, appears := page.Top(ctx, a, albums) + return GetArtistTracks200JSONResponse(ArtistTracks{Top: toTrackResults(top), AppearsOn: toTrackResults(appears)}), nil +} + +// GetArtistRelated returns artists like an artist, and songs by them. +func (s *Server) GetArtistRelated(ctx context.Context, req GetArtistRelatedRequestObject) (GetArtistRelatedResponseObject, error) { + _, page, a, _, done, err := s.artistPage(ctx, req.Id, req.ArtistId) + if err != nil { + return nil, err + } + defer done() + rel, songs := page.Similar(ctx, a) + return GetArtistRelated200JSONResponse(RelatedMusic{Artists: toRelated(rel), Tracks: toTrackResults(songs)}), nil +} + +// GetArtistShuffle returns songs by an artist at random. +func (s *Server) GetArtistShuffle(ctx context.Context, req GetArtistShuffleRequestObject) (GetArtistShuffleResponseObject, error) { + _, page, _, albums, done, err := s.artistPage(ctx, req.Id, req.ArtistId) + if err != nil { + return nil, err + } + defer done() + n := 10 + if req.Params.Limit != nil { + n = min(max(*req.Params.Limit, 1), 50) + } + ts, err := page.Shuffle(ctx, albums, n, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))) //nolint:gosec // not for secrets + if err != nil { + return nil, err + } + return GetArtistShuffle200JSONResponse(toTrackResults(ts)), nil +} + +// GetArtistElsewhere finds the artist on the caller's other links. +func (s *Server) GetArtistElsewhere(ctx context.Context, req GetArtistElsewhereRequestObject) (GetArtistElsewhereResponseObject, error) { + _, sess, err := s.openUsable(ctx, req.Id) + if err != nil { + return nil, err + } + a, _, err := sess.Artist(ctx, req.ArtistId) + sess.Close() + if err != nil { + return nil, err + } + ls, err := s.Links.Usable(ctx, sessionFrom(ctx).User.ID) + if err != nil { + return nil, err + } + found := make([]*ArtistElsewhere, len(ls)) + var wg sync.WaitGroup + for i, l := range ls { + if l.ID == req.Id { + continue + } + wg.Go(func() { + ctx, cancel := context.WithTimeout(ctx, searchTimeout) + defer cancel() + p, err := s.Links.Provider(l.Provider) + if err != nil { + return + } + sess, err := s.Links.Open(ctx, l.ID) + if err != nil { + slog.Debug("artist elsewhere: opening a link", "link", l.ID, "err", err) + return + } + defer sess.Close() + if other := artistpage.Elsewhere(ctx, sess, p.Info().Capabilities, a.Name); other != nil { + found[i] = &ArtistElsewhere{LinkId: l.ID, Provider: l.Provider, AccountLabel: l.AccountLabel, Artist: toArtistResult(*other)} + } + }) + } + wg.Wait() + out := GetArtistElsewhere200JSONResponse{} + for _, f := range found { + if f != nil { + out = append(out, *f) + } + } + return out, nil +} + +// GetGenre returns a genre's artists and songs by them, through a link. +func (s *Server) GetGenre(ctx context.Context, req GetGenreRequestObject) (GetGenreResponseObject, error) { + name := strings.TrimSpace(req.Params.Name) + if name == "" || s.Graph == nil { + return nil, provider.ErrNotFound + } + l, sess, err := s.openUsable(ctx, req.Id) + if err != nil { + return nil, err + } + defer sess.Close() + p, err := s.Links.Provider(l.Provider) + if err != nil { + return nil, err + } + page := artistpage.Page{Sess: sess, Caps: p.Info().Capabilities, Graph: s.Graph} + rel, songs, err := page.Genre(ctx, name) + if err != nil { + return nil, err + } + return GetGenre200JSONResponse(GenreDetail{Name: name, Artists: toRelated(rel), Tracks: toTrackResults(songs)}), nil +} + +// GetAlbumAbout returns what MusicBrainz and Wikipedia know about an album. +func (s *Server) GetAlbumAbout(ctx context.Context, req GetAlbumAboutRequestObject) (GetAlbumAboutResponseObject, error) { + if s.LinerNotes == nil { + return nil, provider.ErrNotFound + } + _, sess, err := s.openUsable(ctx, req.Id) + if err != nil { + return nil, err + } + a, _, err := sess.Album(ctx, req.AlbumId) + sess.Close() + if err != nil { + return nil, err + } + if len(a.Artists) == 0 { + return nil, provider.ErrNotFound + } + n, err := s.LinerNotes.Album(ctx, a.Artists[0].Name, a.Title) + if err != nil { + return nil, err + } + out := AlbumAbout{ + Labels: nonNilStrings(n.Labels), Genres: nonNilStrings(n.Genres), + FirstReleased: optional(n.FirstReleased), About: optional(n.About), AboutUrl: optional(n.AboutURL), + } + if n.Kind != "" { + out.Kind = ptr(AlbumKind(n.Kind)) + } + return GetAlbumAbout200JSONResponse(out), nil +} + +// GetRoomArtistPlays returns an artist's songs the room played most. +func (s *Server) GetRoomArtistPlays(ctx context.Context, req GetRoomArtistPlaysRequestObject) (GetRoomArtistPlaysResponseObject, error) { + n := 10 + if req.Params.Limit != nil { + n = min(max(*req.Params.Limit, 1), 50) + } + plays, err := s.Rooms.Plays(ctx, req.RoomId, time.Time{}, rooms.Forever) + if err != nil { + return nil, err + } + return GetRoomArtistPlays200JSONResponse(toTrackCounts(stats.ArtistTop(plays, req.Params.Name, n))), nil +} + +func toRelated(rs []artistpage.Related) []RelatedArtist { + out := make([]RelatedArtist, len(rs)) + for i, r := range rs { + out[i] = RelatedArtist{Name: r.Name} + if r.Score > 0 { + out[i].Score = ptr(float32(r.Score)) + } + if r.Artist != nil { + out[i].Artist = ptr(toArtistResult(*r.Artist)) + } + } + return out +} + +func toTrackResults(ts []provider.Track) []TrackResult { + out := make([]TrackResult, len(ts)) + for i, t := range ts { + out[i] = toTrackResult(t) + } + return out +} + +// optional is a pointer to s, or nil if it's empty. +func optional(s string) *string { + if s == "" { + return nil + } + return &s +} + +func nonNilStrings(s []string) []string { + if s == nil { + return []string{} + } + return slices.Clone(s) +} diff --git a/server/internal/httpapi/artistpage_test.go b/server/internal/httpapi/artistpage_test.go new file mode 100644 index 0000000..c83531f --- /dev/null +++ b/server/internal/httpapi/artistpage_test.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package httpapi_test + +import ( + "net/http" + "net/url" + "slices" + "strings" + "testing" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/httpapi" +) + +func trackTitles(ts []httpapi.TrackResult) []string { + out := []string{} + for _, t := range ts { + out = append(out, t.Title) + } + return out +} + +func TestArtistPage(t *testing.T) { + e := newEnv(t) + alice := e.admin() + link := linkFake(t, alice) + base := "/links/" + link + "/artists/ar1" + + var about httpapi.ArtistAbout + alice.want(http.StatusOK, "GET", base+"/about", nil).decode(t, &about) + if about.Name != "The Test Patterns" || about.Bio == nil || !strings.Contains(*about.Bio, "sine waves") || about.Mbid == nil { + t.Fatalf("about: %+v", about) + } + if len(about.Facts) != 1 || about.Facts[0].Text != "Formed in 2009" { + t.Errorf("facts: %+v", about.Facts) + } + // The fake doesn't say what its albums are; MusicBrainz does. + if about.AlbumKinds["al1"] != "album" || about.AlbumKinds["al2"] != "ep" { + t.Errorf("album kinds: %v", about.AlbumKinds) + } + + var tracks httpapi.ArtistTracks + alice.want(http.StatusOK, "GET", base+"/tracks", nil).decode(t, &tracks) + if got := trackTitles(tracks.Top); !slices.Equal(got, []string{"Reference Tone", "SMPTE"}) { + t.Errorf("top: %v", got) + } + if len(tracks.AppearsOn) != 0 { + t.Errorf("appears on: %v", trackTitles(tracks.AppearsOn)) + } + + var related httpapi.RelatedMusic + alice.want(http.StatusOK, "GET", base+"/related", nil).decode(t, &related) + if len(related.Artists) != 2 || related.Artists[0].Name != "Null Island" || related.Artists[0].Artist == nil || len(related.Tracks) != 4 { + t.Errorf("related: %+v", related) + } + + var shuffled []httpapi.TrackResult + alice.want(http.StatusOK, "GET", base+"/shuffle?limit=4", nil).decode(t, &shuffled) + if len(shuffled) != 4 { + t.Errorf("shuffle: %v", trackTitles(shuffled)) + } + + var elsewhere []httpapi.ArtistElsewhere + alice.want(http.StatusOK, "GET", base+"/elsewhere", nil).decode(t, &elsewhere) + if len(elsewhere) != 0 { + t.Errorf("elsewhere with one link: %+v", elsewhere) + } + + var album httpapi.AlbumAbout + alice.want(http.StatusOK, "GET", "/links/"+link+"/albums/al1/about", nil).decode(t, &album) + if album.Kind == nil || *album.Kind != "album" || album.FirstReleased == nil || *album.FirstReleased != "2019-03-01" || + !slices.Equal(album.Labels, []string{"Test Card Records"}) || !slices.Equal(album.Genres, []string{"test tones"}) { + t.Errorf("album: %+v", album) + } + if r := alice.do("GET", "/links/"+link+"/albums/al3/about", nil); r.status != http.StatusNotFound { + t.Errorf("unknown album: %d %s", r.status, r.body) + } + // Without the music knowledge layer, there are no genre pages. + if r := alice.do("GET", "/links/"+link+"/genre?name=test+tones", nil); r.status != http.StatusNotFound { + t.Errorf("genre: %d %s", r.status, r.body) + } + if r := alice.do("GET", "/links/"+link+"/artists/nope/about", nil); r.status != http.StatusNotFound { + t.Errorf("unknown artist: %d %s", r.status, r.body) + } +} + +func TestArtistElsewhere(t *testing.T) { + e := newEnv(t) + alice := e.admin() + bob := e.member(alice, "bob") + mine := linkFake(t, alice) + // Bob's shares his: Alice can use it too. + theirs := linkFake(t, bob) + bob.want(http.StatusOK, "PATCH", "/links/"+theirs, map[string]any{"shared": true}) + var found []httpapi.ArtistElsewhere + alice.want(http.StatusOK, "GET", "/links/"+mine+"/artists/ar2/elsewhere", nil).decode(t, &found) + if len(found) != 1 || found[0].LinkId != theirs || found[0].Artist.Name != "Null Island" { + t.Errorf("elsewhere: %+v", found) + } +} + +func TestRoomArtistPlays(t *testing.T) { + e := newEnv(t) + alice := e.admin() + link := linkFake(t, alice) + room := e.room(t, me(t, alice).Id) + var snap httpapi.QueueSnapshot + alice.want(http.StatusOK, "POST", "/rooms/"+room.ID+"/queue", addReq(link, "t01", "t04", "t07")).decode(t, &snap) + start := e.clock() + for i, it := range snap.Items { + e.play(room.ID, it.Id, start.Add(time.Duration(i)*time.Minute)) + } + var tops []httpapi.TrackCount + alice.want(http.StatusOK, "GET", "/rooms/"+room.ID+"/artist-plays?name="+url.QueryEscape("the test patterns"), nil).decode(t, &tops) + var got []string + for _, tc := range tops { + got = append(got, tc.Item.Track.Title) + } + slices.Sort(got) + if !slices.Equal(got, []string{"Reference Tone", "SMPTE"}) { + t.Errorf("plays: %v", got) + } +} diff --git a/server/internal/httpapi/browse.go b/server/internal/httpapi/browse.go index 51a4ce8..24ee0b4 100644 --- a/server/internal/httpapi/browse.go +++ b/server/internal/httpapi/browse.go @@ -457,6 +457,9 @@ func toAlbumResult(a provider.Album) AlbumResult { if a.Artwork != "" { out.Artwork = ptr(string(a.Artwork)) } + if a.Kind != "" { + out.Kind = ptr(AlbumKind(a.Kind)) + } return out } diff --git a/server/internal/httpapi/musicbrainz_test.go b/server/internal/httpapi/musicbrainz_test.go index 3a77dbc..46f7dd1 100644 --- a/server/internal/httpapi/musicbrainz_test.go +++ b/server/internal/httpapi/musicbrainz_test.go @@ -51,6 +51,37 @@ func newMusicBrainz(t *testing.T) *httptest.Server { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"` + refToneArtist + `","name":"The Test Patterns","type":"Group","area":{"name":"Bristol"},` + `"life-span":{"begin":"2009"},"relations":[{"type":"wikidata","url":{"resource":"https://www.wikidata.org/wiki/Q42424242"}}]}`)) + case "/ws/2/artist": + // Searching for an artist by name: only this one is known. + w.Header().Set("Content-Type", "application/json") + if !strings.Contains(r.URL.Query().Get("query"), "The Test Patterns") { + _, _ = w.Write([]byte(`{"artists":[]}`)) + return + } + _, _ = w.Write([]byte(`{"artists":[{"id":"` + refToneArtist + `","name":"The Test Patterns","score":100}]}`)) + case "/ws/2/release-group": + w.Header().Set("Content-Type", "application/json") + if q := r.URL.Query().Get("query"); q != "" { + // Searching for an album. + if !strings.Contains(q, "Calibration") { + _, _ = w.Write([]byte(`{"release-groups":[]}`)) + return + } + _, _ = w.Write([]byte(`{"release-groups":[{"id":"bb000000-0000-4000-8000-0000000000f1","score":100,"title":"Calibration",` + + `"primary-type":"Album","artist-credit":[{"name":"The Test Patterns","artist":{"id":"` + refToneArtist + `","name":"The Test Patterns"}}]}]}`)) + return + } + // An artist's release groups. + _, _ = w.Write([]byte(`{"release-groups":[` + + `{"id":"bb000000-0000-4000-8000-0000000000f1","title":"Calibration","primary-type":"Album","first-release-date":"2019"},` + + `{"id":"bb000000-0000-4000-8000-0000000000f2","title":"Colour Bars","primary-type":"EP","first-release-date":"2021"}]}`)) + case "/ws/2/release-group/bb000000-0000-4000-8000-0000000000f1": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"bb000000-0000-4000-8000-0000000000f1","title":"Calibration","primary-type":"Album",` + + `"first-release-date":"2019-03-01","genres":[{"name":"test tones","count":3}],"relations":[]}`)) + case "/ws/2/release": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"releases":[{"title":"Calibration","date":"2019","label-info":[{"label":{"name":"Test Card Records"}}]}]}`)) case "/w/api.php": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"entities":{"Q42424242":{"sitelinks":{"enwiki":{"title":"The Test Patterns"}}}}}`)) diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 2bc04e3..7c41c61 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -24,6 +24,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/musicgraph" "github.com/madeofpendletonwool/syncphony/server/internal/nights" "github.com/madeofpendletonwool/syncphony/server/internal/palette" "github.com/madeofpendletonwool/syncphony/server/internal/playback" @@ -51,6 +52,9 @@ type Server struct { Lyrics *lyrics.Service // LinerNotes writes songs' liner notes. Nil when MusicBrainz is off. LinerNotes *linernotes.Service + // Graph is what's known about music, for artist and genre pages. + // Nil when no source is on. + Graph *musicgraph.Service // Artwork picks queued songs' covers. Artwork *artwork.Service // Palettes works out queued songs' artwork colors. diff --git a/server/internal/stats/stats.go b/server/internal/stats/stats.go index ec168e6..961a7a4 100644 --- a/server/internal/stats/stats.go +++ b/server/internal/stats/stats.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/madeofpendletonwool/syncphony/server/internal/match" "github.com/madeofpendletonwool/syncphony/server/internal/provider" "github.com/madeofpendletonwool/syncphony/server/internal/rooms" "github.com/madeofpendletonwool/syncphony/server/internal/store" @@ -92,9 +93,9 @@ func Summarize(plays []rooms.Played) Summary { } t.add(p, track) } - s.Totals = room.totals() + s.Totals = room.totals(TopN) for user, t := range people { - s.People = append(s.People, Person{UserID: user, Totals: t.totals()}) + s.People = append(s.People, Person{UserID: user, Totals: t.totals(TopN)}) } slices.SortFunc(s.People, func(a, b Person) int { return cmp.Or(cmp.Compare(b.Plays, a.Plays), cmp.Compare(b.Listening, a.Listening), cmp.Compare(a.UserID, b.UserID)) @@ -148,7 +149,7 @@ func (t *tally) add(p *rooms.Played, track provider.Track) { } } -func (t *tally) totals() Totals { +func (t *tally) totals(n int) Totals { out := Totals{Plays: t.plays, Skipped: t.skipped, Listening: t.listening, TopTracks: []TrackCount{}, TopArtists: []ArtistCount{}} for _, tc := range t.tracks { out.TopTracks = append(out.TopTracks, *tc) @@ -164,8 +165,8 @@ func (t *tally) totals() Totals { slices.SortFunc(out.TopArtists, func(a, b ArtistCount) int { return cmp.Or(cmp.Compare(b.Plays, a.Plays), cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))) }) - out.TopTracks = out.TopTracks[:min(len(out.TopTracks), TopN)] - out.TopArtists = out.TopArtists[:min(len(out.TopArtists), TopN)] + out.TopTracks = out.TopTracks[:min(len(out.TopTracks), n)] + out.TopArtists = out.TopArtists[:min(len(out.TopArtists), n)] return out } @@ -216,3 +217,22 @@ func Sessions(spans []Span) []Session { slices.Reverse(out) return out } + +// ArtistTop returns the artist's songs that played to the end most often, +// most first: at most n. A song counts for every artist credited on it. +func ArtistTop(plays []rooms.Played, artist string, n int) []TrackCount { + want := match.Simplify(artist) + t := newTally() + for i := range plays { + p := &plays[i] + if want == "" || p.EndReason != store.EndFinished { + continue + } + var track provider.Track + _ = json.Unmarshal([]byte(p.Item.Metadata), &track) + if slices.ContainsFunc(track.Artists, func(a provider.ArtistCredit) bool { return match.Simplify(a.Name) == want }) { + t.add(p, track) + } + } + return t.totals(n).TopTracks +} diff --git a/server/internal/stats/stats_test.go b/server/internal/stats/stats_test.go index 9e4272f..7ca4775 100644 --- a/server/internal/stats/stats_test.go +++ b/server/internal/stats/stats_test.go @@ -127,3 +127,24 @@ func TestSessions(t *testing.T) { t.Error("sessions from nothing") } } + +func TestArtistTop(t *testing.T) { + plays := []rooms.Played{ + play(1, "ann", "navidrome", "song-a", "ISRC1", 0, 3, store.EndFinished, "Bowie"), + play(2, "bob", "spotify", "sp-a", "ISRC1", 3, 3, store.EndFinished, "Queen", "David Bowie"), + play(3, "ann", "navidrome", "song-b", "", 6, 3, store.EndFinished, "bowie"), + play(4, "ann", "navidrome", "song-a", "ISRC1", 9, 3, store.EndFinished, "Bowie"), + play(5, "ann", "navidrome", "song-c", "", 12, 1, store.EndSkipped, "Bowie"), + play(6, "ann", "navidrome", "song-d", "", 15, 3, store.EndFinished, "Abba"), + } + // Spelled any way, credited anywhere; skips don't count. + if got, want := tracks(stats.ArtistTop(plays, "BOWIE", 10)), "song-a×2 song-b×1"; got != want { + t.Errorf("Bowie's top %q, want %q", got, want) + } + if got := tracks(stats.ArtistTop(plays, "Bowie", 1)); got != "song-a×2" { + t.Errorf("top 1: %q", got) + } + if got := stats.ArtistTop(plays, "Nobody", 10); got == nil || len(got) != 0 { + t.Errorf("nobody: %v", got) + } +} From 1daa4ad8c9667d91d73ce5925b75796f2821f305 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 7 Oct 2026 18:59:49 -0500 Subject: [PATCH 3/3] Artist, genre and album pages in the web app (MAD-766..771) - Artist page: about line, began/ended facts and genre chips, "Add top 5" and Shuffle, links to the artist on your other services, the Wikipedia bio (tap to expand), top songs, the artist's most played songs in the current room, the discography split into Albums, Singles & EPs, Live and Compilations (other services' albums merged in, tagged), songs they appear on, similar songs, "Fans also like" (artists not on the service open a search), and members. - Genre page (/genre/$linkId?name=): its artists, those on the service first, and songs by them. - Album page: release kind, first release and labels, genre chips, the Wikipedia summary, and more by the artist. - ADR 0013. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0013-artist-pages.md | 39 ++ web/src/api/schema.gen.ts | 466 ++++++++++++++++++ web/src/components/artist-parts.tsx | 190 +++++++ web/src/lib/artist.test.ts | 63 +++ web/src/lib/artist.ts | 157 ++++++ web/src/routeTree.gen.ts | 21 + .../_app/_authed/album.$linkId.$albumId.tsx | 63 ++- .../_app/_authed/artist.$linkId.$artistId.tsx | 233 ++++++++- web/src/routes/_app/_authed/genre.$linkId.tsx | 82 +++ 9 files changed, 1289 insertions(+), 25 deletions(-) create mode 100644 docs/adr/0013-artist-pages.md create mode 100644 web/src/components/artist-parts.tsx create mode 100644 web/src/lib/artist.test.ts create mode 100644 web/src/lib/artist.ts create mode 100644 web/src/routes/_app/_authed/genre.$linkId.tsx diff --git a/docs/adr/0013-artist-pages.md b/docs/adr/0013-artist-pages.md new file mode 100644 index 0000000..3c5bc64 --- /dev/null +++ b/docs/adr/0013-artist-pages.md @@ -0,0 +1,39 @@ +# ADR 0013: Artist, album and genre pages + +- **Status:** accepted +- **Date:** 2026-10-07 +- **Issue:** MAD-763 + +## Context + +An artist's page showed what their service returned: a picture, a name and albums. People browsing a jukebox expect more: who the artist is, their hits, what's like them. The server already knew much of it. Liner notes read Wikipedia bios through MusicBrainz (MAD-717), and the music knowledge layer (ADR 0012) knows artists' top songs, similar artists and tags. + +## Decision + +### What's known says what to show; the link says what plays + +Artist and genre pages are asked for through one link (`/links/{id}/...`), like the artist itself. The `artistpage` package takes what's known about music and looks it up on that link: one search for the artist's songs, matched against their top songs with `match.Score`, then a few one-by-one lookups (`match.On`) for top songs that search missed. Similar artists and a genre's artists cost one search each (artists and songs together), a few at a time. Only songs found on the link are shown, so everything on the page can be added. + +When nothing knows an artist's top songs, the service's own top tracks stand in (`Recommender.TopTracks`), then what its search finds first. When nothing knows who's alike, the service's `SimilarToArtist` does. + +### Notes + +`linernotes` writes artist notes (Wikipedia bio, began/ended facts, members, MusicBrainz genres, release groups) and album notes (release type, first release, labels, genres, Wikipedia summary). They're cached in `page_notes_cache` with the same TTLs as songs' notes. Genres on a page are MusicBrainz's curated ones, then tags the knowledge layer has with weight 0.2 or more, skipping tags that aren't genres ("seen live"). + +### Discography by kind + +`provider.Album.Kind` says whether a release is an album, single, EP, compilation or live, where the service says: Navidrome's OpenSubsonic `releaseTypes`, Spotify's `album_type` (which calls EPs singles). For the rest, the artist's MusicBrainz release groups are matched by title, without edition notes. The page splits albums into Albums, Singles & EPs, Live and Compilations. + +### Genres + +A genre's top artists come from Last.fm's `tag.getTopArtists`, cached in `musicgraph_tags`. Without Last.fm, the artists already cached with the tag stand in, kept for an hour. Genre pages need the knowledge layer; without it they're not found. + +### Across services and rooms + +`/elsewhere` finds the same artist by name on your other usable links. The page links to each and merges their albums into the discography, each once by title, tagged with its service. "Popular in " lists the artist's songs that played to the end most in the current room (`stats.ArtistTop`), to add again. Shuffle adds songs at random from up to six of the artist's albums, spread across them. The fair queue keeps a long add from crowding others out. + +## Consequences + +- The first visit to an artist can take several seconds (MusicBrainz's rate limit, the knowledge layer's sources); sections load on their own, so the page shows what the service knows at once. +- An artist with a common name may be matched to the wrong MusicBrainz artist, as liner notes can be. +- Lyrics previews are left out. diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index a2f0ee2..1ac36dc 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -1051,6 +1051,174 @@ export interface paths { patch?: never; trace?: never; }; + "/links/{id}/artists/{artistId}/about": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + artistId: string; + }; + cookie?: never; + }; + /** + * What's known about an artist + * @description Their bio from Wikipedia, facts, members and genres, from + * MusicBrainz and the music knowledge layer, and the kind of each of + * their albums the service doesn't say. Parts nobody knows are left + * empty. The first time, it can take several seconds; then it's cached. + */ + get: operations["getArtistAbout"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/links/{id}/artists/{artistId}/tracks": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + artistId: string; + }; + cookie?: never; + }; + /** + * An artist's top songs and appearances + * @description Their most popular songs, from what's known about music and else + * the service, matched onto this link, and songs on others' records + * they're credited on. + */ + get: operations["getArtistTracks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/links/{id}/artists/{artistId}/related": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + artistId: string; + }; + cookie?: never; + }; + /** + * Artists like an artist, and songs by them + * @description Similar artists, most alike first, with `artist` set for those on + * this link, and a few songs by each that are. + */ + get: operations["getArtistRelated"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/links/{id}/artists/{artistId}/elsewhere": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + artistId: string; + }; + cookie?: never; + }; + /** + * The same artist on your other services + * @description The artist, by name, on the other links you may use. + */ + get: operations["getArtistElsewhere"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/links/{id}/artists/{artistId}/shuffle": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + artistId: string; + }; + cookie?: never; + }; + /** + * Songs by an artist at random + * @description Picked from across their albums. + */ + get: operations["getArtistShuffle"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/links/{id}/albums/{albumId}/about": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + albumId: string; + }; + cookie?: never; + }; + /** + * What's known about an album + * @description Its kind, first release, labels and genres from MusicBrainz, and a + * summary from Wikipedia. 404 if MusicBrainz doesn't know it, or is off. + */ + get: operations["getAlbumAbout"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/links/{id}/genre": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + /** + * A genre's artists, and songs by them + * @description Its top artists, those on this link first, with a few songs by + * each. 404 if nothing is known about it. + */ + get: operations["getGenre"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/links/{id}/playlists": { parameters: { query?: never; @@ -1301,6 +1469,29 @@ export interface paths { patch?: never; trace?: never; }; + "/rooms/{roomId}/artist-plays": { + parameters: { + query?: never; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + }; + cookie?: never; + }; + /** + * An artist's songs the room played most + * @description Songs credited to the artist, by name, that played to the end in + * this room, most plays first. + */ + get: operations["getRoomArtistPlays"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/rooms/{roomId}/history": { parameters: { query?: never; @@ -2665,7 +2856,13 @@ export interface components { year?: number; trackCount?: number; artwork?: string; + kind?: components["schemas"]["AlbumKind"]; }; + /** + * @description What the service says it is. Services that can't tell an EP from a single call both singles. + * @enum {string} + */ + AlbumKind: "album" | "single" | "ep" | "compilation" | "live"; ArtistResult: { id: string; name: string; @@ -2841,6 +3038,78 @@ export interface components { artist: components["schemas"]["ArtistResult"]; albums: components["schemas"]["AlbumResult"][]; }; + ArtistAbout: { + name: string; + mbid?: string; + /** @description A one-liner, like "Group from Seattle". */ + about?: string; + /** @description A short summary from Wikipedia. */ + bio?: string; + /** @description The Wikipedia article. */ + bioUrl?: string; + facts: components["schemas"]["ArtistFact"][]; + /** @description A group's members, current first. */ + members: components["schemas"]["ArtistMember"][]; + /** @description Most fitting first. */ + genres: string[]; + /** @description The kind of each of the artist's albums the service doesn't say, by album ID. */ + albumKinds: { + [key: string]: components["schemas"]["AlbumKind"]; + }; + }; + ArtistFact: { + /** @enum {string} */ + kind: "began" | "ended"; + /** @description "Formed in Leeds in 1994", "Split up in 2012"... */ + text: string; + }; + ArtistMember: { + name: string; + /** @description The year they joined. */ + from?: number; + /** @description The year they left. */ + to?: number; + current: boolean; + roles: string[]; + }; + ArtistTracks: { + /** @description Most popular first. */ + top: components["schemas"]["TrackResult"][]; + /** @description Songs on others' records they're credited on. */ + appearsOn: components["schemas"]["TrackResult"][]; + }; + RelatedArtist: { + name: string; + /** @description How alike, from 0 to 1, when known. */ + score?: number; + artist?: components["schemas"]["ArtistResult"]; + }; + RelatedMusic: { + artists: components["schemas"]["RelatedArtist"][]; + tracks: components["schemas"]["TrackResult"][]; + }; + GenreDetail: { + name: string; + /** @description Those on the link first. */ + artists: components["schemas"]["RelatedArtist"][]; + tracks: components["schemas"]["TrackResult"][]; + }; + ArtistElsewhere: { + linkId: string; + provider: string; + accountLabel: string; + artist: components["schemas"]["ArtistResult"]; + }; + AlbumAbout: { + kind?: components["schemas"]["AlbumKind"]; + /** @description YYYY, YYYY-MM or YYYY-MM-DD. */ + firstReleased?: string; + labels: string[]; + genres: string[]; + /** @description A short summary from Wikipedia. */ + about?: string; + aboutUrl?: string; + }; PlaylistResult: { id: string; name: string; @@ -4919,6 +5188,177 @@ export interface operations { default: components["responses"]["Error"]; }; }; + getArtistAbout: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + artistId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description What's known about an artist */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtistAbout"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + getArtistTracks: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + artistId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description An artist's top songs and appearances */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtistTracks"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + getArtistRelated: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + artistId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Artists like an artist, and songs by them */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RelatedMusic"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + getArtistElsewhere: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + artistId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The same artist on your other services */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtistElsewhere"][]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + getArtistShuffle: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path: { + id: string; + artistId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Songs by an artist at random */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrackResult"][]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + getAlbumAbout: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + albumId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description What's known about the album */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlbumAbout"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + getGenre: { + parameters: { + query: { + name: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The genre */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GenreDetail"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; listPlaylists: { parameters: { query?: { @@ -5208,6 +5648,32 @@ export interface operations { default: components["responses"]["Error"]; }; }; + getRoomArtistPlays: { + parameters: { + query: { + name: string; + limit?: number; + }; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The artist's most played songs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrackCount"][]; + }; + }; + default: components["responses"]["Error"]; + }; + }; getHistory: { parameters: { query?: { diff --git a/web/src/components/artist-parts.tsx b/web/src/components/artist-parts.tsx new file mode 100644 index 0000000..f93290c --- /dev/null +++ b/web/src/components/artist-parts.tsx @@ -0,0 +1,190 @@ +import { Link } from '@tanstack/react-router' +import { ExternalLink, Search, Users } from 'lucide-react' +import { motion } from 'motion/react' +import { useState, type ReactNode } from 'react' +import { ArtistCard } from '@/components/album-card' +import { Shelf } from '@/components/shelf' +import { TrackRow } from '@/components/track-row' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { useAddToLane } from '@/hooks/use-add-to-lane' +import { memberYears, rolesLine, type ArtistMember, type RelatedArtist } from '@/lib/artist' +import type { TrackResult } from '@/lib/browse' +import { fadeUp, stagger } from '@/lib/motion' +import { cn } from '@/lib/utils' + +/** A page section: a heading, with an optional action beside it. */ +export function PageSection({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { + return ( +
+
+

{title}

+ {action} +
+ {children} +
+ ) +} + +/** Genres as chips that open their pages. */ +export function GenreChips({ genres, linkId, className }: { genres: string[]; linkId: string; className?: string }) { + if (genres.length === 0) return null + return ( +
+ {genres.map((g) => ( + + {g} + + ))} +
+ ) +} + +/** A Wikipedia summary that opens up when tapped, with its source. */ +export function Bio({ text, url, label = 'Read more on Wikipedia' }: { text: string; url?: string; label?: string }) { + const [open, setOpen] = useState(false) + return ( + + + {url && ( + + {label} + + + )} + + ) +} + +/** A band's members, current first. */ +export function Members({ members }: { members: ArtistMember[] }) { + return ( + + {members.map((m) => { + const line = [rolesLine(m.roles), memberYears(m)].filter(Boolean).join(' · ') + return ( + + + + +
+

{m.name}

+ {line &&

{line}

} +
+
+ ) + })} +
+ ) +} + +/** + * Related artists in a row: those on the link open their page, the rest + * search for them everywhere. + */ +export function RelatedShelf({ artists, linkId }: { artists: RelatedArtist[]; linkId: string }) { + return ( + + {artists.map((r) => ( +
+ {r.artist ? ( + + ) : ( + + + + {r.name.charAt(0).toUpperCase()} + +

{r.name}

+

+ + Search +

+ +
+ )} +
+ ))} +
+ ) +} + +/** Songs with one-tap adds, and an add-them-all action when there are a few. */ +export function SongList({ + tracks, + numbered, + hideAlbum, + note, +}: { + tracks: TrackResult[] + numbered?: boolean + hideAlbum?: boolean + note?: (t: TrackResult) => string | undefined +}) { + const { add, status } = useAddToLane() + return ( + + {tracks.map((t, i) => ( + add([t])} + number={numbered ? i + 1 : undefined} + hideAlbum={hideAlbum} + note={note?.(t)} + /> + ))} + + ) +} + +/** Adds the songs not already in your lane. */ +export function AddAllButton({ tracks, label }: { tracks: TrackResult[]; label: string }) { + const { add, status } = useAddToLane() + const waiting = tracks.filter((t) => status(t) === 'idle') + return ( + + ) +} + +/** Rows of placeholder songs. */ +export function SongsSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) +} diff --git a/web/src/lib/artist.test.ts b/web/src/lib/artist.test.ts new file mode 100644 index 0000000..c04d233 --- /dev/null +++ b/web/src/lib/artist.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { albumKey, discography, kindOf, memberYears, mergeAlbums, rolesLine, type LinkedAlbum } from './artist' + +const album = (id: string, title: string, year?: number, kind?: LinkedAlbum['kind'], linkId = 'l1'): LinkedAlbum => ({ + id, + title, + artists: [], + year, + kind, + linkId, +}) + +describe('discography', () => { + it('splits albums into shelves by kind, newest first', () => { + const albums = [ + album('a1', 'First', 1994), + album('a2', 'Second', 1998), + album('s1', 'Hit', 1997, 'single'), + album('e1', 'Extended', 1995), + album('c1', 'Best Of', 2010, 'compilation'), + ] + const shelves = discography(albums, { e1: 'ep', a1: 'live' }) + expect(shelves.map((s) => [s.title, s.albums.map((a) => a.id)])).toEqual([ + ['Albums', ['a2']], + ['Singles & EPs', ['s1', 'e1']], + ['Live', ['a1']], + ['Compilations', ['c1']], + ]) + }) + + it("trusts the service over MusicBrainz, and calls what nobody knows an album", () => { + expect(kindOf(album('x', 'X', 1, 'single'), { x: 'live' })).toBe('single') + expect(kindOf(album('x', 'X'), undefined)).toBe('album') + }) +}) + +describe('mergeAlbums', () => { + it("adds other services' albums it doesn't have, by title", () => { + const own = [album('a1', 'OK Computer')] + const other = [album('b1', 'OK Computer (Deluxe Edition)', 1997, undefined, 'l2'), album('b2', 'Kid A', 2000, undefined, 'l2')] + const third = [album('c1', 'kid a', 2000, undefined, 'l3')] + expect(mergeAlbums(own, [other, third]).map((a) => `${a.linkId}:${a.id}`)).toEqual(['l1:a1', 'l2:b2']) + }) + + it('compares titles without edition notes, accents or punctuation', () => { + expect(albumKey('Déjà Vu - 2021 Remaster')).toBe('deja vu') + expect(albumKey('Rock & Roll!')).toBe('rock and roll') + }) +}) + +describe('members', () => { + it('says when someone was in the band', () => { + expect(memberYears({ name: 'A', current: false, roles: [], from: 1994, to: 2012 })).toBe('1994–2012') + expect(memberYears({ name: 'A', current: true, roles: [], from: 1996 })).toBe('Since 1996') + expect(memberYears({ name: 'A', current: false, roles: [], to: 1999 })).toBe('Until 1999') + expect(memberYears({ name: 'A', current: true, roles: [] })).toBe('') + }) + + it('lists roles', () => { + expect(rolesLine(['guitar', 'lead vocals'])).toBe('Guitar, lead vocals') + expect(rolesLine([])).toBe('') + }) +}) diff --git a/web/src/lib/artist.ts b/web/src/lib/artist.ts new file mode 100644 index 0000000..dcb0d90 --- /dev/null +++ b/web/src/lib/artist.ts @@ -0,0 +1,157 @@ +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/api/client' +import { unwrap } from '@/api/errors' +import type { components } from '@/api/schema.gen' +import type { AlbumResult } from '@/lib/browse' + +export type ArtistAbout = components['schemas']['ArtistAbout'] +export type ArtistMember = components['schemas']['ArtistMember'] +export type AlbumKind = components['schemas']['AlbumKind'] +export type RelatedArtist = components['schemas']['RelatedArtist'] +export type ArtistElsewhere = components['schemas']['ArtistElsewhere'] +export type AlbumAbout = components['schemas']['AlbumAbout'] + +// What's known about artists changes slowly; the server caches it for days. +const SLOW = 30 * 60_000 + +/** An artist's bio, facts, members, genres and the kinds of their albums. */ +export const artistAboutQuery = (linkId: string, artistId: string) => + queryOptions({ + queryKey: ['artist', linkId, artistId, 'about'], + queryFn: () => unwrap(api.GET('/links/{id}/artists/{artistId}/about', { params: { path: { id: linkId, artistId } } })), + staleTime: SLOW, + }) + +/** An artist's top songs, and songs on others' records they're on. */ +export const artistTracksQuery = (linkId: string, artistId: string) => + queryOptions({ + queryKey: ['artist', linkId, artistId, 'tracks'], + queryFn: () => unwrap(api.GET('/links/{id}/artists/{artistId}/tracks', { params: { path: { id: linkId, artistId } } })), + staleTime: SLOW, + }) + +/** Artists like an artist, and songs by them. */ +export const artistRelatedQuery = (linkId: string, artistId: string) => + queryOptions({ + queryKey: ['artist', linkId, artistId, 'related'], + queryFn: () => unwrap(api.GET('/links/{id}/artists/{artistId}/related', { params: { path: { id: linkId, artistId } } })), + staleTime: SLOW, + }) + +/** The same artist on your other services. */ +export const artistElsewhereQuery = (linkId: string, artistId: string) => + queryOptions({ + queryKey: ['artist', linkId, artistId, 'elsewhere'], + queryFn: () => unwrap(api.GET('/links/{id}/artists/{artistId}/elsewhere', { params: { path: { id: linkId, artistId } } })), + staleTime: SLOW, + }) + +/** Songs by an artist at random, fresh every time. */ +export function artistShuffle(linkId: string, artistId: string, limit = 10) { + return unwrap( + api.GET('/links/{id}/artists/{artistId}/shuffle', { params: { path: { id: linkId, artistId }, query: { limit } } }), + ) +} + +/** The artist's songs this room played most. */ +export const roomArtistPlaysQuery = (roomId: string, name: string) => + queryOptions({ + queryKey: ['history', roomId, 'artist', name], + queryFn: () => unwrap(api.GET('/rooms/{roomId}/artist-plays', { params: { path: { roomId }, query: { name, limit: 5 } } })), + staleTime: 60_000, + }) + +/** A genre's artists, and songs by them, through a link. */ +export const genreQuery = (linkId: string, name: string) => + queryOptions({ + queryKey: ['genre', linkId, name.toLowerCase()], + queryFn: () => unwrap(api.GET('/links/{id}/genre', { params: { path: { id: linkId }, query: { name } } })), + staleTime: SLOW, + }) + +/** An album's kind, first release, labels, genres and summary. */ +export const albumAboutQuery = (linkId: string, albumId: string) => + queryOptions({ + queryKey: ['album', linkId, albumId, 'about'], + queryFn: () => unwrap(api.GET('/links/{id}/albums/{albumId}/about', { params: { path: { id: linkId, albumId } } })), + staleTime: SLOW, + retry: false, + }) + +/** The shelves a discography splits into, in order. */ +export const shelves = [ + { title: 'Albums', kinds: ['album'] }, + { title: 'Singles & EPs', kinds: ['single', 'ep'] }, + { title: 'Live', kinds: ['live'] }, + { title: 'Compilations', kinds: ['compilation'] }, +] as const satisfies readonly { title: string; kinds: readonly AlbumKind[] }[] + +/** An album with the link it's on, for discographies mixing services. */ +export type LinkedAlbum = AlbumResult & { linkId: string } + +/** What kind an album is: what its service says, else MusicBrainz, else an album. */ +export function kindOf(album: AlbumResult, kinds: Record | undefined): AlbumKind { + return album.kind ?? kinds?.[album.id] ?? 'album' +} + +/** "Single", "EP", "Live album"... for an album's subtitle. */ +export function kindLabel(kind: AlbumKind) { + return { album: 'Album', single: 'Single', ep: 'EP', live: 'Live album', compilation: 'Compilation' }[kind] +} + +/** + * Splits a discography into shelves, newest first, leaving out empty ones. + * `kinds` are the kinds MusicBrainz knows of the albums whose service + * doesn't say. + */ +export function discography(albums: LinkedAlbum[], kinds?: Record) { + const newest = [...albums].sort((a, b) => (b.year ?? 0) - (a.year ?? 0)) + return shelves + .map((s) => ({ title: s.title, albums: newest.filter((a) => (s.kinds as readonly AlbumKind[]).includes(kindOf(a, kinds))) })) + .filter((s) => s.albums.length > 0) +} + +/** An album title without edition notes, to compare: "OK Computer (Deluxe)" is "ok computer". */ +export function albumKey(title: string) { + return title + .toLowerCase() + .replace(/\s*[([].*?[)\]]/g, '') + .replace(/\s+-\s+.*$/, '') + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/&/g, ' and ') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() +} + +/** + * Adds other services' albums to a discography: only those it doesn't + * already have, by title, each once. + */ +export function mergeAlbums(own: LinkedAlbum[], others: LinkedAlbum[][]): LinkedAlbum[] { + const seen = new Set(own.map((a) => albumKey(a.title))) + const out = [...own] + for (const list of others) { + for (const a of list) { + const k = albumKey(a.title) + if (seen.has(k)) continue + seen.add(k) + out.push(a) + } + } + return out +} + +/** When someone was in a band: "1994–2012", "Since 1996", "Until 1999". */ +export function memberYears(m: ArtistMember) { + if (m.from && m.to) return m.from === m.to ? String(m.from) : `${m.from}–${m.to}` + if (m.from) return m.current ? `Since ${m.from}` : `From ${m.from}` + if (m.to) return `Until ${m.to}` + return '' +} + +/** "guitar, lead vocals" → "Guitar, lead vocals". */ +export function rolesLine(roles: string[]) { + const s = roles.join(', ') + return s.charAt(0).toUpperCase() + s.slice(1) +} diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 81cf9e4..84bba97 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as AppAuthedHistoryRouteImport } from './routes/_app/_authed/hist import { Route as AppAuthedMeRouteImport } from './routes/_app/_authed/me' import { Route as AppAuthedRoomRouteImport } from './routes/_app/_authed/room' import { Route as AppAuthedSearchRouteImport } from './routes/_app/_authed/search' +import { Route as AppAuthedGenreLinkIdRouteImport } from './routes/_app/_authed/genre.$linkId' import { Route as AppAuthedSettingsPeopleRouteImport } from './routes/_app/_authed/settings.people' import { Route as AppAuthedSettingsProfileRouteImport } from './routes/_app/_authed/settings.profile' import { Route as AppAuthedSettingsSecurityRouteImport } from './routes/_app/_authed/settings.security' @@ -100,6 +101,11 @@ const AppAuthedSearchRoute = AppAuthedSearchRouteImport.update({ path: '/search', getParentRoute: () => AppAuthedRoute, } as any) +const AppAuthedGenreLinkIdRoute = AppAuthedGenreLinkIdRouteImport.update({ + id: '/genre/$linkId', + path: '/genre/$linkId', + getParentRoute: () => AppAuthedRoute, +} as any) const AppAuthedSettingsPeopleRoute = AppAuthedSettingsPeopleRouteImport.update({ id: '/settings/people', path: '/settings/people', @@ -160,6 +166,7 @@ export interface FileRoutesByFullPath { '/me': typeof AppAuthedMeRoute '/room': typeof AppAuthedRoomRoute '/search': typeof AppAuthedSearchRoute + '/genre/$linkId': typeof AppAuthedGenreLinkIdRoute '/settings/people': typeof AppAuthedSettingsPeopleRoute '/settings/profile': typeof AppAuthedSettingsProfileRoute '/settings/security': typeof AppAuthedSettingsSecurityRoute @@ -182,6 +189,7 @@ export interface FileRoutesByTo { '/me': typeof AppAuthedMeRoute '/room': typeof AppAuthedRoomRoute '/search': typeof AppAuthedSearchRoute + '/genre/$linkId': typeof AppAuthedGenreLinkIdRoute '/settings/people': typeof AppAuthedSettingsPeopleRoute '/settings/profile': typeof AppAuthedSettingsProfileRoute '/settings/security': typeof AppAuthedSettingsSecurityRoute @@ -207,6 +215,7 @@ export interface FileRoutesById { '/_app/_authed/room': typeof AppAuthedRoomRoute '/_app/_authed/search': typeof AppAuthedSearchRoute '/_app/_authed/': typeof AppAuthedIndexRoute + '/_app/_authed/genre/$linkId': typeof AppAuthedGenreLinkIdRoute '/_app/_authed/settings/people': typeof AppAuthedSettingsPeopleRoute '/_app/_authed/settings/profile': typeof AppAuthedSettingsProfileRoute '/_app/_authed/settings/security': typeof AppAuthedSettingsSecurityRoute @@ -231,6 +240,7 @@ export interface FileRouteTypes { | '/me' | '/room' | '/search' + | '/genre/$linkId' | '/settings/people' | '/settings/profile' | '/settings/security' @@ -253,6 +263,7 @@ export interface FileRouteTypes { | '/me' | '/room' | '/search' + | '/genre/$linkId' | '/settings/people' | '/settings/profile' | '/settings/security' @@ -277,6 +288,7 @@ export interface FileRouteTypes { | '/_app/_authed/room' | '/_app/_authed/search' | '/_app/_authed/' + | '/_app/_authed/genre/$linkId' | '/_app/_authed/settings/people' | '/_app/_authed/settings/profile' | '/_app/_authed/settings/security' @@ -397,6 +409,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAuthedSearchRouteImport parentRoute: typeof AppAuthedRoute } + '/_app/_authed/genre/$linkId': { + id: '/_app/_authed/genre/$linkId' + path: '/genre/$linkId' + fullPath: '/genre/$linkId' + preLoaderRoute: typeof AppAuthedGenreLinkIdRouteImport + parentRoute: typeof AppAuthedRoute + } '/_app/_authed/settings/people': { id: '/_app/_authed/settings/people' path: '/settings/people' @@ -462,6 +481,7 @@ interface AppAuthedRouteChildren { AppAuthedRoomRoute: typeof AppAuthedRoomRoute AppAuthedSearchRoute: typeof AppAuthedSearchRoute AppAuthedIndexRoute: typeof AppAuthedIndexRoute + AppAuthedGenreLinkIdRoute: typeof AppAuthedGenreLinkIdRoute AppAuthedSettingsPeopleRoute: typeof AppAuthedSettingsPeopleRoute AppAuthedSettingsProfileRoute: typeof AppAuthedSettingsProfileRoute AppAuthedSettingsSecurityRoute: typeof AppAuthedSettingsSecurityRoute @@ -478,6 +498,7 @@ const AppAuthedRouteChildren: AppAuthedRouteChildren = { AppAuthedRoomRoute: AppAuthedRoomRoute, AppAuthedSearchRoute: AppAuthedSearchRoute, AppAuthedIndexRoute: AppAuthedIndexRoute, + AppAuthedGenreLinkIdRoute: AppAuthedGenreLinkIdRoute, AppAuthedSettingsPeopleRoute: AppAuthedSettingsPeopleRoute, AppAuthedSettingsProfileRoute: AppAuthedSettingsProfileRoute, AppAuthedSettingsSecurityRoute: AppAuthedSettingsSecurityRoute, diff --git a/web/src/routes/_app/_authed/album.$linkId.$albumId.tsx b/web/src/routes/_app/_authed/album.$linkId.$albumId.tsx index 77b416f..0f82251 100644 --- a/web/src/routes/_app/_authed/album.$linkId.$albumId.tsx +++ b/web/src/routes/_app/_authed/album.$linkId.$albumId.tsx @@ -3,6 +3,8 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { ListPlus } from 'lucide-react' import { motion } from 'motion/react' import { errorMessage } from '@/api/errors' +import { AlbumCard } from '@/components/album-card' +import { Bio, GenreChips, PageSection } from '@/components/artist-parts' import { Artwork } from '@/components/artwork' import { BackButton } from '@/components/back-button' import { Notice } from '@/components/notice' @@ -11,7 +13,9 @@ import { TrackRow } from '@/components/track-row' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { useAddToLane } from '@/hooks/use-add-to-lane' -import { albumQuery, artworkUrl, totalDuration } from '@/lib/browse' +import { albumAboutQuery, kindLabel, type AlbumAbout } from '@/lib/artist' +import { albumQuery, artistQuery, artworkUrl, totalDuration } from '@/lib/browse' +import { Shelf } from '@/components/shelf' import { easeOutExpo, fadeUp, stagger } from '@/lib/motion' import { providersQuery } from '@/lib/services' @@ -26,6 +30,7 @@ const MAX_ADD = 100 function Album() { const { linkId, albumId } = Route.useParams() const album = useQuery(albumQuery(linkId, albumId)) + const about = useQuery(albumAboutQuery(linkId, albumId)) const providers = useQuery(providersQuery) const { add, status } = useAddToLane() @@ -53,7 +58,14 @@ function Album() { const { album: a, tracks, provider } = album.data const p = providers.data?.find((p) => p.id === provider) const waiting = tracks.filter((t) => status(t) === 'idle') - const meta = [a.year, `${tracks.length} song${tracks.length === 1 ? '' : 's'}`, totalDuration(tracks)].filter(Boolean) + const kind = a.kind ?? about.data?.kind + const meta = [ + kind && kind !== 'album' && kindLabel(kind), + a.year, + `${tracks.length} song${tracks.length === 1 ? '' : 's'}`, + totalDuration(tracks), + ].filter(Boolean) + const firstArtist = a.artists.find((ar) => ar.id) return ( <> @@ -96,6 +108,8 @@ function Album() { {p && } {meta.join(' · ')} + {about.data && } + {about.data && } + + + {elsewhere.data && elsewhere.data.length > 0 && ( + + {elsewhere.data.map((e) => ( + + {iconOf(e.provider) && } + Also on {e.accountLabel} + + ))} + + )} + + + {n?.bio && ( +
+ +
+ )} + + + + + {shelves.map((s) => ( + + + {s.albums.map((al) => { + const kind = kindOf(al, kinds) + const subtitle = [al.year, kind === 'album' ? al.trackCount && `${al.trackCount} songs` : kindLabel(kind)] + const p = al.linkId === linkId ? undefined : others.find((q) => q.data?.linkId === al.linkId)?.data?.provider + return ( + + ) + })} + + + ))} + + {tracks.data && tracks.data.appearsOn.length > 0 && ( + + + + )} + + {related.isPending ? ( + + + + ) : ( + related.data && ( + <> + {related.data.tracks.length > 0 && ( + }> + + + )} + {related.data.artists.length > 0 && ( + + + + )} + + ) + )} + + {n && n.members.length > 0 && ( + + + + )} ) } + +function TopSongs({ tracks, pending }: { tracks: LaneTrack[]; pending: boolean }) { + if (pending) { + return ( + + + + ) + } + if (tracks.length === 0) return null + return ( + + + + ) +} + +/** The artist's songs the current room played most, to add again. */ +function PopularHere({ name }: { name: string }) { + const { room } = useAddToLane() + const plays = useQuery({ ...roomArtistPlaysQuery(room?.id ?? '', name), enabled: !!room }) + const links = useQuery(usableLinksQuery) + if (!room || !plays.data) return null + const usable = new Set(links.data?.filter((l) => l.status === 'ok').map((l) => l.id)) + const counts = new Map() + const tracks: LaneTrack[] = [] + for (const { item, plays: n } of plays.data) { + const linkId = item.track.linkId + if (!linkId || !(usable.has(linkId) || room.matching.borrow)) continue + const t = laneTrackOf(item, linkId) + counts.set(t.trackId, n) + tracks.push(t) + } + if (tracks.length === 0) return null + return ( + + `${counts.get(t.trackId)} play${counts.get(t.trackId) === 1 ? '' : 's'} here`} /> + + ) +} diff --git a/web/src/routes/_app/_authed/genre.$linkId.tsx b/web/src/routes/_app/_authed/genre.$linkId.tsx new file mode 100644 index 0000000..f21583b --- /dev/null +++ b/web/src/routes/_app/_authed/genre.$linkId.tsx @@ -0,0 +1,82 @@ +import { useQuery } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { Tag } from 'lucide-react' +import { motion } from 'motion/react' +import { ApiError, errorMessage } from '@/api/errors' +import { AddAllButton, PageSection, RelatedShelf, SongList, SongsSkeleton } from '@/components/artist-parts' +import { BackButton } from '@/components/back-button' +import { Notice } from '@/components/notice' +import { Skeleton } from '@/components/ui/skeleton' +import { genreQuery } from '@/lib/artist' +import { fadeUp, stagger } from '@/lib/motion' + +export const Route = createFileRoute('/_app/_authed/genre/$linkId')({ + validateSearch: (search: Record): { name: string } => ({ + name: typeof search.name === 'string' ? search.name.trim() : '', + }), + component: Genre, +}) + +/** A genre's artists, those on the service first, and songs by them. */ +function Genre() { + const { linkId } = Route.useParams() + const { name } = Route.useSearch() + const genre = useQuery({ ...genreQuery(linkId, name), enabled: !!name }) + + return ( + <> + + + + + + + {name || 'Genre'} + + + Genre + + + + {genre.isPending && name ? ( + <> + +
+ {Array.from({ length: 4 }, (_, i) => ( + + ))} +
+
+ + + + + ) : !name || (genre.error instanceof ApiError && genre.error.status === 404) ? ( + + Nothing is known about this genre yet. + + ) : genre.isError ? ( + {errorMessage(genre.error)} + ) : ( + genre.data && ( + <> + {genre.data.artists.length > 0 && ( + + + + )} + {genre.data.tracks.length > 0 ? ( + }> + + + ) : ( + + None of its artists are on this service. + + )} + + ) + )} + + ) +}