Files
yellowjacket/backend/explore/shelves.go
T
yonluandClaude Opus 5 e7748f1fd5
CI / check (push) Successful in 3m7s
CI / e2e (push) Canceled after 1m45s
feat(database): shape the library like files, and shrink the catalog
Plans 013 and 014, the album page that prompted them, and the smaller
fixes they turned up. Changelog, largest first.

## The local library is shaped like files, not like MusicBrainz

`audio_files` carries its own tags and points at `albums` and
`artists`; `file_genres` is the one real many-to-many. `recordings`,
`release_group_recordings`, `artist_credit`, `artist_credit_artist`,
`recording_genres`, `release_groups` and `release_to_rg` are gone from
the local side, and with them a six-way join in every read, a
`MIN(release_group_id)` subquery in eleven queries and a
first-credited-artist subquery in nine. Measured on a real 25,966-file
library, every many-to-many that model expressed was 1:1 in the data.

- Ownership is a file. `GetFilePathsByRecordingMBIDs`,
  `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
  `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812
  orphaned recordings, 216 release groups and 260 artists that library
  carried are now structurally impossible.
- One projection: every track query selects from the `track_metadata`
  view, one row type, one mapper. Nine hand-rolled copies had drifted
  far enough to report different years on different screens.
- `library_id = 0` means every library, so each list query exists once
  instead of scoped and unscoped with a branch at every call site.
- No migration chain. `sql/schemas/` is the one description of the
  shape; `sql/migrations/`, `applyMigrations` and `schema_migrations`
  are squashed away, along with the drift between them that had sqlc
  generating against a stale schema.
- `database.InsertTestTrack` is the one test seeder; twenty test files
  had been assembling the old FK chain each in its own order.

## The catalog stores its ids as bytes

`explore_index`'s three 36-char MBID columns and its entity-type text
are 16 raw bytes and a small integer. The table and its six indexes go
780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh
install is ~0.6 GB rather than ~1.0 GB.

- `backend/explore/mbid.go` is the only place the encoding is known;
  everything above it speaks dashed strings.
- `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert
  rather than silently returning no rows, since SQLite does not coerce
  between TEXT and BLOB.
- The importer asks the artifact what encoding it carries and converts
  on the way in, so the artifact already published keeps working and no
  format bump is needed.
- `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column
  list, and `TestStoredEncodingRoundTrips` sweeps every read path.

## An album page that says how much of the album is yours

- One question, asked once: is there a file. `filePaths` is filled by a
  single batched lookup when the tracklist settles, and the badge, the
  Play count, the dimmed rows and every menu item read it — replacing
  four claims of decreasing confidence that could show a green tick on
  an album whose every action did nothing.
- Play, Play 7 of 12, or no play button at all.
- `total_tracks` on `explore_index` (~2 bytes over 400,677 release
  groups) and on `audio_files` from tags that have always carried it:
  a complete MBID-matched album now makes no catalog call at all, where
  it used to spend the most expensive request the app makes.
- A merged cluster shows the running order the most releases agree on,
  and the version list marks the release you own rather than standing a
  synthetic entry in for it.
- `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed
  one by a 12-second timer.
- Rows not in the library are dimmed in place (with `aria-disabled`)
  instead of the owned ones wearing a green tick and a legend.

## Caches and cover art get ceilings

- Only the three tiers of a cover are stored; the full-resolution copy
  nothing rendered was 1,134 MB of a 1.4 GB covers directory.
- One artist portrait is downloaded and the rest are remembered as
  URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads.
- `browsedArtBudget` and `httpCacheBudget` bound what an age cannot:
  the same install held art for 5,770 artists in a 1,301-artist
  library.
- `OrphanedArtistImagesJob` joined a bare MBID onto a sharded
  directory, so it deleted the rows that were the only record of the
  files it left behind. `explore.ArtistImageDir` is that layout's one
  definition now.

## The autotag queue asks whether there is work

`tagging_items` was a row per album folder, not a queue, and no query
read the `tag_status` column that held the answer. The four queue
queries ask the files, which matters most where it is least visible:
`startPrefetch` was scoring every album in a tagged library against
MusicBrainz.

## Phantom playlist tracks resolve in place

An M3U8 imported before its files leaves phantom rows; they now match
by path and fall back to position, keep their place in the playlist
when resolved, and pair best-first so two phantoms cannot claim the
same file.

## Playing a track plays the list it is in

Double-click, and Play on a single row's menu, queue the list as
displayed with `startIndex` on that row — the album page and the track
list used to queue one track and discard the album around it. A
multi-row selection still plays exactly itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-16 13:58:15 -04:00

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 == EntityArtist {
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 ?`,
dbEntityType(entityType), limit+len(skip),
))
out := make([]SearchIndexResult, 0, limit)
for _, row := range rows {
key := row.ArtistMBID
if entityType == EntityArtist {
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 = 2 /* release_group */
AND in_library = 0
AND artist_mbid IN (
SELECT artist_mbid FROM explore_index
WHERE entity_type = 2 /* release_group */
AND in_library = 1
AND artist_mbid != x''
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
}