`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.
481 lines
16 KiB
Go
481 lines
16 KiB
Go
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
|
|
}
|