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
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 == 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
|
|
}
|