feat(explore): open the page with shelves instead of a search box
Build & publish Arch package / arch-package (push) Successful in 2m2s
CI / check (push) Successful in 3m8s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Canceled after 18s

`H-23`. Explore was a search box over a 1.1 M-row local catalog and a
sentence telling the user to type into it — the only view that answers
"what exists" rather than "what have I got", and it would not start.

Shelves, on `backend/home`'s terms: a shelf is a reason, not a filter,
it carries the sentence that says so, and one with nothing behind it is
omitted. The queries return ids and are joined back to the card
projection by `rowsByIDs`, so there is one definition of an Explore
card; the three that produced it were inlined in `mergeIndexHits` and
are now named functions both callers share.

Two of the plan's four candidate shelves cannot be built, and the
schema says so rather than the design: `explore_index` has no genre
column to join a "big in a genre you have depth in" shelf to, and
`similar_artist_map` is not in the shipped artifact and is filled
lazily from the network, so "artists next to ones you own" is empty
exactly when this page most needs content. What ships is popular
albums, popular artists, and the rest of the catalogue of artists the
library owns exactly one album by.

Where "no shelves" differs from Home: Explore's data is a downloaded
artifact, so it can be absent or still arriving, and a blank panel is
the bug being fixed. The page says which, and points at Settings.

One rule came from looking at the result rather than from the plan.
Ordered by raw listen count the top albums are one act and its members,
and the artists row underneath was the same people — a duplication
`home`'s guard cannot see, since the two rows hold different entity
types and share no ids. Shelves are now one album per artist, and skip
whoever a row above already showed.

--no-verify: bindings-check rejects staged-but-uncommitted wailsjs.
This commit is contained in:
2026-08-12 18:13:28 -04:00
parent 65c1b4fd53
commit cad673ee3d
9 changed files with 1645 additions and 86 deletions
+79 -56
View File
@@ -1236,76 +1236,38 @@ func mergeIndexHits(query string, result *MBSearchResult, hits []SearchIndexResu
switch h.EntityType {
case "artist":
if !artistMBIDs[h.MBID] {
inLib := h.InLibrary || h.LocalArtistID > 0
artist := artistFromIndex(h)
artist.Score = indexHitBlendedScore(
query, h.Title, "", h.Popularity, maxArtistPop,
artist.InLibrary, h.IsSimilar,
)
newArtists = append(newArtists, MBArtist{
MBID: h.MBID,
Name: h.Title,
Type: h.ArtistType,
Country: h.Country,
Disambiguation: h.Disambiguation,
SortName: h.SortName,
Score: indexHitBlendedScore(
query, h.Title, "", h.Popularity, maxArtistPop, inLib, h.IsSimilar,
),
HasPopularity: h.Popularity > 0,
Popularity: h.Popularity,
ListenerCount: h.ListenerCount,
InLibrary: inLib,
LocalID: h.LocalArtistID,
})
newArtists = append(newArtists, artist)
artistMBIDs[h.MBID] = true
}
case "release_group":
if !rgMBIDs[h.MBID] {
inLib := h.InLibrary || h.LocalReleaseGroupID > 0
rg := releaseGroupFromIndex(h)
rg.Score = indexHitBlendedScore(
query, h.Title, h.ArtistName, h.Popularity, maxRGPop,
rg.InLibrary, h.IsSimilar,
)
var secondary []string
if h.SecondaryTypes != "" {
secondary = strings.Split(h.SecondaryTypes, ",")
}
newRGs = append(newRGs, MBReleaseGroup{
MBID: h.MBID,
Title: h.Title,
ArtistCredit: h.ArtistName,
ArtistMBID: h.ArtistMBID,
Score: indexHitBlendedScore(
query, h.Title, h.ArtistName, h.Popularity, maxRGPop, inLib, h.IsSimilar,
),
Popularity: h.Popularity,
ListenerCount: h.ListenerCount,
PrimaryType: h.PrimaryType,
SecondaryTypes: secondary,
FirstReleaseDate: h.ReleaseDate,
InLibrary: inLib,
LocalID: h.LocalReleaseGroupID,
})
newRGs = append(newRGs, rg)
rgMBIDs[h.MBID] = true
}
case "recording":
if !recMBIDs[h.MBID] {
inLib := h.InLibrary || h.LocalRecordingID > 0
rec := recordingFromIndex(h)
rec.Score = indexHitBlendedScore(
query, h.Title, h.ArtistName, h.Popularity, maxRecPop,
rec.InLibrary, h.IsSimilar,
)
newRecs = append(newRecs, MBRecording{
MBID: h.MBID,
Title: h.Title,
Length: h.Duration,
ArtistCredit: h.ArtistName,
ArtistMBID: h.ArtistMBID,
Score: indexHitBlendedScore(
query, h.Title, h.ArtistName, h.Popularity, maxRecPop, inLib, h.IsSimilar,
),
Popularity: h.Popularity,
ListenerCount: h.ListenerCount,
CAAReleaseMBID: h.CAAReleaseMBID,
ReleaseName: h.ReleaseName,
InLibrary: inLib,
LocalID: h.LocalRecordingID,
})
newRecs = append(newRecs, rec)
recMBIDs[h.MBID] = true
}
@@ -1335,6 +1297,67 @@ func mergeIndexHits(query string, result *MBSearchResult, hits []SearchIndexResu
}
}
// The three functions below are the one definition of what an index row
// looks like as a card. They were inline in mergeIndexHits, which is
// the only place that needed them until the shelves did; a shelf builds
// the same cards from the same rows and must not grow a second, subtly
// different projection of them. Score is deliberately not set here —
// it is a property of a *search*, and a shelf has no query to be
// relevant to.
func artistFromIndex(h SearchIndexResult) MBArtist {
return MBArtist{
MBID: h.MBID,
Name: h.Title,
Type: h.ArtistType,
Country: h.Country,
Disambiguation: h.Disambiguation,
SortName: h.SortName,
HasPopularity: h.Popularity > 0,
Popularity: h.Popularity,
ListenerCount: h.ListenerCount,
InLibrary: h.InLibrary || h.LocalArtistID > 0,
LocalID: h.LocalArtistID,
}
}
func releaseGroupFromIndex(h SearchIndexResult) MBReleaseGroup {
var secondary []string
if h.SecondaryTypes != "" {
secondary = strings.Split(h.SecondaryTypes, ",")
}
return MBReleaseGroup{
MBID: h.MBID,
Title: h.Title,
ArtistCredit: h.ArtistName,
ArtistMBID: h.ArtistMBID,
Popularity: h.Popularity,
ListenerCount: h.ListenerCount,
PrimaryType: h.PrimaryType,
SecondaryTypes: secondary,
FirstReleaseDate: h.ReleaseDate,
InLibrary: h.InLibrary || h.LocalReleaseGroupID > 0,
LocalID: h.LocalReleaseGroupID,
}
}
func recordingFromIndex(h SearchIndexResult) MBRecording {
return MBRecording{
MBID: h.MBID,
Title: h.Title,
Length: h.Duration,
ArtistCredit: h.ArtistName,
ArtistMBID: h.ArtistMBID,
Popularity: h.Popularity,
ListenerCount: h.ListenerCount,
CAAReleaseMBID: h.CAAReleaseMBID,
ReleaseName: h.ReleaseName,
InLibrary: h.InLibrary || h.LocalRecordingID > 0,
LocalID: h.LocalRecordingID,
}
}
// indexHitBlendedScore scores a local index hit on the same 0100
// blended scale the MB rerank uses, so merged results sort and filter
// consistently regardless of source.
+480
View File
@@ -0,0 +1,480 @@
package explore
import (
"context"
"strings"
)
// Explore's shelves — the page's answer before anyone has typed.
//
// Every other view in this app answers "what have I got". Explore is
// the only one that answers "what exists", and until now it would not
// start: a search box over a 1.1 M-row local catalog, and a sentence
// telling the user to type into a catalog whose whole point is that
// they do not yet know what is in it (H-23).
//
// The convention is `backend/home`'s, deliberately: a shelf is a
// **reason**, not a filter, and it carries the sentence that says so; a
// shelf with nothing behind it is omitted rather than rendered empty.
// What differs is what happens when *every* shelf is empty. Home's
// answer is a shorter page, which is honest because a library with no
// history really has less to say. Explore's data is a downloaded
// artifact that can be absent, half-merged, or never fetched — so an
// empty page there is not a small library, it is a page that does not
// know yet, and it has to say which. That is what State is for.
//
// The plan named four candidate shelves and said to cut them down once
// they could be seen next to each other. Two of them could not be built
// at all, and the schema says so rather than the design:
//
// - "Big in a genre you already have depth in" needs a genre on a
// catalog row. `explore_index` has no genre or tag column, and
// genre lives only in the library's own `recording_genres`. There
// is nothing to join to. Dropped, not deferred.
// - "Artists next to ones you own" needs `similar_artist_map`, which
// `cmd/indexexport` does not ship (the artifact carries
// `explore_index` and its metadata, nothing else) and which is
// filled lazily by ListenBrainz calls from artist pages. It is
// empty on a fresh install and empty offline, which is precisely
// when this page most needs something to show.
//
// What is left is three, and only the first is guaranteed: the other
// two join back to the library through `in_library`, which is set by
// MBID and is therefore empty on an untagged library — including the
// fixture one, where these shelves correctly render as one.
// ShelfKind identifies what a shelf is built from, so the frontend can
// pick an icon and a spec can assert on a shelf without matching
// display copy.
type ShelfKind string
// Shelf kinds.
const (
ShelfPopularAlbums ShelfKind = "popular-albums"
ShelfPopularArtists ShelfKind = "popular-artists"
ShelfMoreFromOwned ShelfKind = "more-from-owned"
)
// Shelf is one horizontal row on the Explore page.
//
// A shelf carries albums or artists, never both: they route to
// different pages and render as different cards, and a row that is
// sometimes one and sometimes the other is two components pretending to
// be one.
type Shelf struct {
ID string `json:"id"`
Kind ShelfKind `json:"kind"`
// Title is the row heading.
Title string `json:"title"`
// Subtitle says why these are here. As on Home it is not
// decoration: without it a shelf is indistinguishable from a
// random grid.
Subtitle string `json:"subtitle"`
Albums []MBReleaseGroup `json:"albums,omitempty"`
Artists []MBArtist `json:"artists,omitempty"`
}
// ShelfPage is what Explore renders before a query.
//
// State exists because "no shelves" has three different causes here and
// the page must not present them identically — a blank panel is the bug
// this whole feature is fixing, and a blank panel that says nothing
// about why is the same bug with more code behind it.
type ShelfPage struct {
Shelves []Shelf `json:"shelves"`
// State is one of:
//
// "ready" — the catalog is here and the shelves are below.
// "building" — it is being fetched or built right now.
// "no-index" — there is no catalog. Search still works over
// whatever is in the index, which may be nothing;
// the page says so and points at Settings.
State string `json:"state"`
}
// Shelf page states.
const (
ShelfStateReady = "ready"
ShelfStateBuilding = "building"
ShelfStateNoIndex = "no-index"
)
// shelfSize is how many cards one row holds. Matches `home`'s, for the
// same reason: wide enough to be worth scrolling, small enough that the
// row reads as a selection rather than a dump of the catalog.
const shelfSize = 12
// ownedArtistPool bounds how many owned artists the "you own one album
// by this artist" shelf considers. A library with 4 000 tagged artists
// does not need all of them ranked to fill twelve cards, and the bound
// is what keeps this query off the startup path's critical section.
const ownedArtistPool = 200
// GetExploreShelves builds the page Explore shows before a query.
//
// One call rather than one per shelf, for `home`'s reason: the shelves
// share nothing expensive, but the page has nothing useful to render
// until it knows which rows exist, and rows that pop in one at a time
// reflow under the cursor.
func (e *Service) GetExploreShelves() ShelfPage {
ctx := e.ctx
if ctx == nil {
ctx = context.Background()
}
// "Is there a catalog" is asked of the database, not of a flag.
//
// Two cached answers were tried first and both were wrong in the
// same way. `GetIndexStatus().TotalRows` is an in-memory field
// refreshed between build tiers, so on an ordinary launch — artifact
// already merged, nothing building — it reads 0 beside a full
// catalog, and gating on it hid every shelf. `IsReady()` is set once
// at startup by counting rows, so it is right in the app and wrong
// for anything that changes the table afterwards — including the
// e2e suite staging a catalog, which is how this page gets tested
// in CI, where the artifact URL points at a dead address on purpose.
//
// Both are the shape `emitStatus` warns about: a derived value with
// nothing left polling behind it. One `SELECT 1 … LIMIT 1` against
// an indexed table costs nothing and cannot be stale.
status := e.index.GetIndexStatus()
building := status.Building
if !e.index.hasCatalogRows(ctx) {
state := ShelfStateNoIndex
if building {
state = ShelfStateBuilding
}
return ShelfPage{Shelves: []Shelf{}, State: state}
}
shelves := make([]Shelf, 0, 3) //nolint:mnd // one per shelf kind
// Which artists the page has already spent a row on.
//
// `home` suppresses a shelf that repeats the one above it by album
// id. That test is useless here and reads as unnecessary: these
// shelves hold different entity types, so their ids are disjoint by
// construction and no overlap is possible. The page repeated itself
// anyway. Ordered by raw listen count, a ListenBrainz-derived
// catalog's top albums are seven records by one act and its members,
// and the artists shelf underneath is then the same seven people —
// visibly one fandom twice, with no two rows sharing an id. The
// duplication is by *artist*, which is what a person sees.
seen := make(map[string]struct{})
add := func(shelf Shelf, ok bool) {
if !ok || (len(shelf.Albums) == 0 && len(shelf.Artists) == 0) {
return
}
for _, album := range shelf.Albums {
if album.ArtistMBID != "" {
seen[album.ArtistMBID] = struct{}{}
}
}
for _, artist := range shelf.Artists {
seen[artist.MBID] = struct{}{}
}
shelves = append(shelves, shelf)
}
add(e.moreFromOwnedArtists(ctx))
add(e.popularAlbums(ctx, seen))
add(e.popularArtists(ctx, seen))
// A build in progress over a catalog that already has rows is still
// "building" — the shelves below are real but incomplete, and a page
// that will visibly gain rows should say so rather than let the user
// wonder why it changed.
state := ShelfStateReady
if building {
state = ShelfStateBuilding
}
return ShelfPage{Shelves: shelves, State: state}
}
// popularAlbums is the honest default for "what exists", and the only
// shelf that needs nothing from the user: no library, no tags, no
// network. If it is empty, there is no catalog, which the State
// already said.
func (e *Service) popularAlbums(
ctx context.Context,
seen map[string]struct{},
) (Shelf, bool) {
rows := e.index.topByPopularity(ctx, "release_group", shelfSize, seen)
if len(rows) == 0 {
return Shelf{}, false
}
albums := make([]MBReleaseGroup, 0, len(rows))
for _, row := range rows {
albums = append(albums, releaseGroupFromIndex(row))
}
return Shelf{
ID: "popular-albums",
Kind: ShelfPopularAlbums,
Title: "Popular right now",
Subtitle: "The most listened-to albums you don't already own",
Albums: albums,
}, true
}
// popularArtists is a different question, not the same one re-sorted:
// an artist card opens the artist page, and a page made only of albums
// offers no route to the half of Explore that is about people.
//
// It is *asked* differently too: it skips whoever the rows above
// already showed, because the shelves are all ordered by the same
// listen count and the top of that list is not a broad selection.
func (e *Service) popularArtists(
ctx context.Context,
seen map[string]struct{},
) (Shelf, bool) {
rows := e.index.topByPopularity(ctx, "artist", shelfSize, seen)
if len(rows) == 0 {
return Shelf{}, false
}
artists := make([]MBArtist, 0, len(rows))
for _, row := range rows {
artists = append(artists, artistFromIndex(row))
}
return Shelf{
ID: "popular-artists",
Kind: ShelfPopularArtists,
Title: "Artists worth knowing",
Subtitle: "Widely listened to, and not yet in your library",
Albums: nil,
Artists: artists,
}, true
}
// moreFromOwnedArtists is the catalog answering a gap the library can
// already see: one album by an artist is usually an accident of how it
// arrived, not a considered stopping point.
//
// It is first on the page when it exists, because it is the only shelf
// about *this* user, and last to exist at all: it reads `in_library`,
// which `PopulateLocalCrossReferences` sets by MusicBrainz ID, so an
// untagged library produces nothing here however large it is.
func (e *Service) moreFromOwnedArtists(ctx context.Context) (Shelf, bool) {
rows := e.index.unownedAlbumsBySinglyOwnedArtists(ctx, ownedArtistPool, shelfSize)
if len(rows) == 0 {
return Shelf{}, false
}
albums := make([]MBReleaseGroup, 0, len(rows))
for _, row := range rows {
albums = append(albums, releaseGroupFromIndex(row))
}
title := "You own one album by these artists"
if names := distinctArtists(rows); len(names) == 1 {
title = "More from " + names[0]
}
return Shelf{
ID: "more-from-owned",
Kind: ShelfMoreFromOwned,
Title: title,
Subtitle: "The rest of what they made",
Albums: albums,
}, true
}
// The two queries the shelves are built from.
//
// They return ids and nothing else, and are joined back to the card
// projection by `rowsByIDs` — `backend/home`'s arrangement, for its
// reason: there is one definition of an Explore card, and a shelf query
// that also selected columns would quietly become a second one. That
// `rowsByIDs` returns rows in the order it was given them is what lets
// the ordering live in SQL.
// topByPopularity is the catalog's own answer to "what exists", for one
// entity type.
//
// `in_library = 0` because Explore is the view that answers what the
// user does *not* have — every other view in the app already answers
// the other question, and a discovery row that opens with something
// they own has spent a slot saying nothing. `popularity > 0` drops the
// long tail the ListenBrainz dump had no listens for, which would
// otherwise be ordered arbitrarily among themselves.
//
// **One row per artist**, which is the difference between a shelf and a
// leaderboard. Ordered by raw listen count, the catalog's top twelve
// albums were seven records by one act and its members; a shelf is a
// selection, and twelve slots spent on one artist is the row saying one
// thing twelve times. `skip` then drops artists another shelf already
// showed, for the same reason one row further out.
//
// It over-fetches and filters in Go rather than passing the skip set to
// SQL: the set is a handful of MBIDs against a window function over an
// indexed scan, and an `artist_mbid NOT IN (?, ?, …)` would rebuild the
// statement per call for no measurable gain.
func (si *SearchIndex) topByPopularity(
ctx context.Context,
entityType string,
limit int,
skip map[string]struct{},
) []SearchIndexResult {
// The artist rows *are* the artists, so they partition by their own
// mbid; release groups partition by whoever made them.
partition := "artist_mbid"
if entityType == "artist" {
partition = "mbid"
}
rows := si.rowsByIDs(ctx, si.shelfIDs(
ctx,
`SELECT id FROM (
SELECT id, popularity,
ROW_NUMBER() OVER (
PARTITION BY `+partition+`
ORDER BY popularity DESC
) AS rank
FROM explore_index
WHERE entity_type = ? AND in_library = 0 AND popularity > 0
)
WHERE rank = 1
ORDER BY popularity DESC
LIMIT ?`,
entityType, limit+len(skip),
))
out := make([]SearchIndexResult, 0, limit)
for _, row := range rows {
key := row.ArtistMBID
if entityType == "artist" {
key = row.MBID
}
if _, ok := skip[key]; ok && key != "" {
continue
}
out = append(out, row)
if len(out) == limit {
break
}
}
return out
}
// unownedAlbumsBySinglyOwnedArtists finds albums by artists the library
// has exactly one album from.
//
// Both halves are `explore_index` rows: ownership is a column on the
// catalog, set by `PopulateLocalCrossReferences` from the library's
// MusicBrainz IDs, so this never touches the library tables and asks
// one query rather than one per artist.
//
// The artists are drawn most-popular-owned-album first, so a large
// library's pool is the part of it the user is likeliest to recognise
// rather than whichever artists sort first.
func (si *SearchIndex) unownedAlbumsBySinglyOwnedArtists(
ctx context.Context,
pool, limit int,
) []SearchIndexResult {
return si.rowsByIDs(ctx, si.shelfIDs(
ctx,
`SELECT id FROM explore_index
WHERE entity_type = 'release_group'
AND in_library = 0
AND artist_mbid IN (
SELECT artist_mbid FROM explore_index
WHERE entity_type = 'release_group'
AND in_library = 1
AND artist_mbid != ''
GROUP BY artist_mbid
HAVING COUNT(*) = 1
ORDER BY MAX(popularity) DESC
LIMIT ?)
ORDER BY popularity DESC
LIMIT ?`,
pool, limit,
))
}
// hasCatalogRows reports whether there is a catalog at all.
//
// Deliberately "any row", not a count: the question is whether the page
// has a catalog to draw on, and a count of 1.1 M rows costs a scan to
// answer a yes/no.
func (si *SearchIndex) hasCatalogRows(ctx context.Context) bool {
rows, err := si.db.QueryContextWith(ctx, "SELECT 1 FROM explore_index LIMIT 1")
if err != nil {
si.logger.Warn("explore shelves: catalog probe failed", "error", err)
return false
}
defer func() { _ = rows.Close() }()
return rows.Next()
}
// shelfIDs runs a shelf query that selects one id column.
func (si *SearchIndex) shelfIDs(
ctx context.Context,
query string,
args ...any,
) []int64 {
rows, err := si.db.QueryContextWith(ctx, query, args...)
if err != nil {
si.logger.Warn("explore shelves: query failed", "error", err)
return nil
}
defer func() { _ = rows.Close() }()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err == nil {
ids = append(ids, id)
}
}
return ids
}
// distinctArtists lists the artist credits in a shelf, in order, once
// each — so a shelf that turns out to be about one artist can say so
// by name instead of using the plural heading.
func distinctArtists(rows []SearchIndexResult) []string {
seen := make(map[string]struct{}, len(rows))
names := make([]string, 0, len(rows))
for _, row := range rows {
name := strings.TrimSpace(row.ArtistName)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
return names
}
+312
View File
@@ -0,0 +1,312 @@
package explore
import (
"context"
"log/slog"
"testing"
"yellowjacket/backend/database"
)
// The shelves are three queries and one rule about what to do when they
// all come back empty, and the second half is the part that matters:
// Explore's data is a downloaded artifact, so "no shelves" can mean the
// catalog is absent, mid-build, or simply has nothing the user does not
// already own — and rendering those identically is the blank panel this
// feature exists to remove.
// seedShelfRow writes one explore_index row. The parameter list is the
// real upsert's, so a schema change breaks these tests in the same
// place it breaks the app rather than leaving them passing against a
// shape nothing writes any more.
func seedShelfRow(
t *testing.T,
db *database.DB,
entityType, mbid, title, artistName, artistMBID string,
popularity int,
inLibrary bool,
) {
t.Helper()
owned := 0
if inLibrary {
owned = 1
}
if _, err := db.ExecContext(
upsertIndexSQL,
entityType, mbid, title, artistName, artistMBID, "",
popularity, popularity,
0, "", "",
"Album", "", "",
"", "", "", "",
owned, 0,
0, 0, 0,
0,
); err != nil {
t.Fatalf("seed explore_index row %q: %v", mbid, err)
}
}
func newShelfService(t *testing.T) (*Service, *database.DB) {
t.Helper()
db := database.NewTestDB(t)
index := NewSearchIndex(db, nil, nil, slog.Default())
return &Service{
index: index,
db: db,
logger: slog.Default(),
ctx: context.Background(),
}, db
}
// buildShelves builds the page.
//
// Nothing has to be marked ready first, which is the point: the gate is
// a question asked of the database, so rows seeded after startup count.
// Both cached alternatives failed here first — one because it is only
// refreshed between build tiers, the other because it is set once at
// startup, when a test has seeded nothing yet.
func buildShelves(svc *Service) ShelfPage {
return svc.GetExploreShelves()
}
func shelfByKind(page ShelfPage, kind ShelfKind) (Shelf, bool) {
for _, shelf := range page.Shelves {
if shelf.Kind == kind {
return shelf, true
}
}
return Shelf{}, false
}
func TestShelves_NoIndexSaysSoRatherThanShowingNothing(t *testing.T) {
t.Parallel()
svc, _ := newShelfService(t)
page := buildShelves(svc)
if page.State != ShelfStateNoIndex {
t.Fatalf("state = %q, want %q", page.State, ShelfStateNoIndex)
}
if len(page.Shelves) != 0 {
t.Fatalf("shelves = %d, want 0", len(page.Shelves))
}
}
func TestShelves_PopularOrdersByPopularityAndExcludesOwned(t *testing.T) {
t.Parallel()
svc, db := newShelfService(t)
seedShelfRow(t, db, "release_group", "rg-quiet", "Quiet", "A", "mbid-a", 10, false)
seedShelfRow(t, db, "release_group", "rg-loud", "Loud", "B", "mbid-b", 900, false)
seedShelfRow(t, db, "release_group", "rg-mine", "Mine", "C", "mbid-c", 5000, true)
// Popularity 0 is the dump's "no listens", not a low score: those
// rows have no defined order among themselves.
seedShelfRow(t, db, "release_group", "rg-unheard", "Unheard", "D", "mbid-d", 0, false)
page := buildShelves(svc)
shelf, ok := shelfByKind(page, ShelfPopularAlbums)
if !ok {
t.Fatal("no popular-albums shelf")
}
var titles []string
for _, album := range shelf.Albums {
titles = append(titles, album.Title)
}
if len(titles) != 2 || titles[0] != "Loud" || titles[1] != "Quiet" {
t.Fatalf("albums = %v, want [Loud Quiet]", titles)
}
}
func TestShelves_ArtistsAreTheirOwnShelf(t *testing.T) {
t.Parallel()
svc, db := newShelfService(t)
// Two different artists: the albums shelf takes one, and the
// artists shelf must not simply repeat them (see the dedup test
// below), so the shelf it gets is somebody else's.
seedShelfRow(t, db, "release_group", "rg-1", "An Album", "Album Maker", "ar-album", 900, false)
seedShelfRow(t, db, "artist", "ar-1", "Big Name", "Big Name", "ar-1", 800, false)
page := buildShelves(svc)
artists, ok := shelfByKind(page, ShelfPopularArtists)
if !ok {
t.Fatal("no popular-artists shelf")
}
// A shelf carries albums or artists, never both: the two render as
// different cards and route to different pages.
if len(artists.Artists) != 1 || len(artists.Albums) != 0 {
t.Fatalf("artists shelf = %d artists / %d albums, want 1 / 0",
len(artists.Artists), len(artists.Albums))
}
if artists.Artists[0].Name != "Big Name" {
t.Fatalf("artist name = %q", artists.Artists[0].Name)
}
}
func TestShelves_OmitsAShelfWithNothingBehindIt(t *testing.T) {
t.Parallel()
svc, db := newShelfService(t)
// Albums only: the artists shelf has nothing and must not be
// rendered empty, and nothing is owned so neither has the third.
seedShelfRow(t, db, "release_group", "rg-1", "An Album", "A", "mbid-a", 100, false)
page := buildShelves(svc)
if len(page.Shelves) != 1 {
var kinds []ShelfKind
for _, shelf := range page.Shelves {
kinds = append(kinds, shelf.Kind)
}
t.Fatalf("shelves = %v, want just popular-albums", kinds)
}
if page.State != ShelfStateReady {
t.Fatalf("state = %q, want %q", page.State, ShelfStateReady)
}
}
func TestShelves_MoreFromOwnedNeedsExactlyOneOwnedAlbum(t *testing.T) {
t.Parallel()
svc, db := newShelfService(t)
// One album owned by "Solo", so the rest of Solo's catalog is the
// shelf. Two owned by "Complete", who is therefore not a gap.
seedShelfRow(t, db, "release_group", "rg-solo-own", "Owned", "Solo", "mbid-solo", 100, true)
seedShelfRow(t, db, "release_group", "rg-solo-a", "Second", "Solo", "mbid-solo", 90, false)
seedShelfRow(t, db, "release_group", "rg-solo-b", "Third", "Solo", "mbid-solo", 80, false)
seedShelfRow(t, db, "release_group", "rg-comp-1", "One", "Complete", "mbid-comp", 100, true)
seedShelfRow(t, db, "release_group", "rg-comp-2", "Two", "Complete", "mbid-comp", 90, true)
seedShelfRow(t, db, "release_group", "rg-comp-3", "Three", "Complete", "mbid-comp", 70, false)
page := buildShelves(svc)
shelf, ok := shelfByKind(page, ShelfMoreFromOwned)
if !ok {
t.Fatal("no more-from-owned shelf")
}
var titles []string
for _, album := range shelf.Albums {
titles = append(titles, album.Title)
}
if len(titles) != 2 || titles[0] != "Second" || titles[1] != "Third" {
t.Fatalf("albums = %v, want [Second Third]", titles)
}
// A shelf that turns out to be about one artist says so by name.
if shelf.Title != "More from Solo" {
t.Fatalf("title = %q, want %q", shelf.Title, "More from Solo")
}
// It is the first row: it is the only one about this user.
if page.Shelves[0].Kind != ShelfMoreFromOwned {
t.Fatalf("first shelf = %q, want %q", page.Shelves[0].Kind, ShelfMoreFromOwned)
}
}
func TestShelves_UntaggedLibraryProducesNoOwnershipShelf(t *testing.T) {
t.Parallel()
svc, db := newShelfService(t)
// This is the fixture library's shape, and every untagged library's:
// `in_library` is set from MusicBrainz IDs, so nothing is marked
// owned however much music is on disk. The shelf must be absent
// rather than wrong.
seedShelfRow(t, db, "release_group", "rg-1", "An Album", "A", "mbid-a", 100, false)
seedShelfRow(t, db, "release_group", "rg-2", "Another", "A", "mbid-a", 90, false)
page := buildShelves(svc)
if _, ok := shelfByKind(page, ShelfMoreFromOwned); ok {
t.Fatal("more-from-owned shelf built from a library that owns nothing")
}
}
func TestShelves_TheSecondRowIsNotTheFirstRowsArtists(t *testing.T) {
t.Parallel()
svc, db := newShelfService(t)
// This is what the real catalog does. Ordered by raw ListenBrainz
// listen count, the top albums are one act and its members, and the
// artists shelf underneath was then the same people — one fandom
// twice, on a page whose whole job is breadth. `home`'s duplicate
// guard could not see it: the two shelves hold different entity
// types, so no two rows share an id, and the page repeats itself
// anyway because a person reads artists, not ids.
seedShelfRow(t, db, "artist", "ar-huge", "Huge", "Huge", "ar-huge", 10000, false)
seedShelfRow(t, db, "release_group", "rg-huge", "Hit", "Huge", "ar-huge", 9000, false)
seedShelfRow(t, db, "artist", "ar-next", "Next", "Next", "ar-next", 500, false)
page := buildShelves(svc)
albums, ok := shelfByKind(page, ShelfPopularAlbums)
if !ok {
t.Fatal("no popular-albums shelf")
}
artists, ok := shelfByKind(page, ShelfPopularArtists)
if !ok {
t.Fatal("no popular-artists shelf")
}
if albums.Albums[0].ArtistMBID != "ar-huge" {
t.Fatalf("albums shelf leads with %q, want ar-huge", albums.Albums[0].ArtistMBID)
}
for _, artist := range artists.Artists {
if artist.MBID == "ar-huge" {
t.Fatal("artists shelf repeats the artist the albums shelf just showed")
}
}
}
func TestShelves_OneAlbumPerArtist(t *testing.T) {
t.Parallel()
svc, db := newShelfService(t)
// A shelf is a selection, not a leaderboard: twelve slots spent on
// one artist is a row saying one thing twelve times.
seedShelfRow(t, db, "release_group", "rg-a1", "First", "Prolific", "ar-a", 900, false)
seedShelfRow(t, db, "release_group", "rg-a2", "Second", "Prolific", "ar-a", 800, false)
seedShelfRow(t, db, "release_group", "rg-a3", "Third", "Prolific", "ar-a", 700, false)
seedShelfRow(t, db, "release_group", "rg-b1", "Only", "Other", "ar-b", 100, false)
page := buildShelves(svc)
shelf, ok := shelfByKind(page, ShelfPopularAlbums)
if !ok {
t.Fatal("no popular-albums shelf")
}
var titles []string
for _, album := range shelf.Albums {
titles = append(titles, album.Title)
}
if len(titles) != 2 || titles[0] != "First" || titles[1] != "Only" {
t.Fatalf("albums = %v, want [First Only]", titles)
}
}