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
528 lines
14 KiB
Go
528 lines
14 KiB
Go
package explore
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bufio"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Incremental listen-count refresh. ListenBrainz publishes a small
|
|
// (~250MB) incremental spark dump every day containing only the listens
|
|
// submitted since the previous dump, in the same parquet-in-tar format
|
|
// as the full dump. This folds those daily deltas into the index's
|
|
// popularity numbers additively, so recordings, artists, and albums stay
|
|
// fresh without re-streaming the ~170GB full dump and without a single
|
|
// ListenBrainz API call.
|
|
//
|
|
// Album (release-group) popularity is derived locally: the incremental
|
|
// gives per-release listen counts, which are rolled up to their release
|
|
// group via the release_to_rg map captured during the full import.
|
|
//
|
|
// Correctness: each dump's deltas plus the high-water-mark advance are
|
|
// applied in one transaction, so a crash can't half-apply a dump, and
|
|
// the high-water-mark guarantees each dump is applied at most once. The
|
|
// sum of the full dump plus every incremental is therefore the exact
|
|
// cumulative listen count — the additive model does not drift.
|
|
|
|
const (
|
|
// defaultIncrementalBaseURL is where ListenBrainz publishes daily
|
|
// incremental listen dumps.
|
|
defaultIncrementalBaseURL = "https://data.metabrainz.org/pub/musicbrainz/listenbrainz/incremental/"
|
|
|
|
// listensCatchupTsKey records when the incremental refresh last ran
|
|
// (RFC3339), gating how often it re-checks for new dumps.
|
|
listensCatchupTsKey = "listens_last_catchup"
|
|
|
|
// listensCatchupInterval is the default minimum time between refresh
|
|
// checks. Album/track popularity is slow-moving and each dump is a
|
|
// ~250MB download, so a weekly cadence keeps the index current while
|
|
// bounding background network use.
|
|
listensCatchupInterval = 7 * 24 * time.Hour
|
|
|
|
// deltaInsertBatch bounds rows per multi-row INSERT into the temp
|
|
// delta table during a single incremental apply.
|
|
deltaInsertBatch = 500
|
|
|
|
// releaseLookupBatch bounds release MBIDs per release_to_rg lookup.
|
|
releaseLookupBatch = 500
|
|
)
|
|
|
|
var (
|
|
incrementalDirRe = regexp.MustCompile(`^listenbrainz-dump-\d+-\d{8}-\d+-incremental$`)
|
|
incrementalFileRe = regexp.MustCompile(`^listenbrainz-spark-dump-.*-incremental\.tar$`)
|
|
)
|
|
|
|
// incrementalDump identifies one daily incremental dump.
|
|
type incrementalDump struct {
|
|
series int
|
|
url string
|
|
}
|
|
|
|
// RefreshListenCounts folds any incremental dumps newer than the current
|
|
// high-water-mark into the index's popularity numbers. It is a no-op
|
|
// when there is no completed baseline import, when a full build is
|
|
// running, when the last refresh was within minInterval (pass 0 to
|
|
// force), or when offline. Runs synchronously — callers wanting the
|
|
// background behaviour should invoke it in a goroutine.
|
|
func (si *SearchIndex) RefreshListenCounts(ctx context.Context, minInterval time.Duration) {
|
|
if !si.hasMeta(dumpImportDoneKey) {
|
|
si.logger.Info("incremental refresh: no baseline import yet, skipping")
|
|
|
|
return
|
|
}
|
|
|
|
si.mu.RLock()
|
|
building := si.cancel != nil
|
|
si.mu.RUnlock()
|
|
|
|
if building {
|
|
si.logger.Info("incremental refresh: full build running, skipping")
|
|
|
|
return
|
|
}
|
|
|
|
if minInterval > 0 && si.refreshedWithin(minInterval) {
|
|
si.logger.Info("incremental refresh: checked recently, skipping")
|
|
|
|
return
|
|
}
|
|
|
|
hwm, ok := si.metaInt(listensAppliedSeriesKey)
|
|
if !ok {
|
|
si.logger.Warn("incremental refresh: no baseline series recorded, skipping")
|
|
|
|
return
|
|
}
|
|
|
|
client := &http.Client{}
|
|
|
|
dumps, err := discoverIncrementalDumps(ctx, client, defaultIncrementalBaseURL, hwm)
|
|
if err != nil {
|
|
si.logger.Warn("incremental refresh: discovery failed", "error", err)
|
|
|
|
return
|
|
}
|
|
|
|
// Record the check even when there is nothing to apply, so the
|
|
// cadence gate holds regardless of outcome.
|
|
defer si.setMeta(listensCatchupTsKey, time.Now().UTC().Format(time.RFC3339))
|
|
|
|
if len(dumps) == 0 {
|
|
si.logger.Info("incremental refresh: up to date", "throughSeries", hwm)
|
|
|
|
return
|
|
}
|
|
|
|
si.logger.Info("incremental refresh: applying dumps",
|
|
"count", len(dumps), "fromSeries", hwm+1, "toSeries", dumps[len(dumps)-1].series,
|
|
)
|
|
|
|
applied := 0
|
|
through := hwm
|
|
|
|
for _, d := range dumps {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
|
|
if err := si.applyIncremental(ctx, client, d); err != nil {
|
|
// Stop at the first failure; the high-water-mark is only
|
|
// advanced on success, so the next run resumes from here.
|
|
si.logger.Warn("incremental refresh: apply failed, stopping",
|
|
"series", d.series, "error", err)
|
|
|
|
break
|
|
}
|
|
|
|
applied++
|
|
through = d.series
|
|
}
|
|
|
|
if applied > 0 {
|
|
// Popularity changed, so refresh the champion tier that backs
|
|
// generic short-prefix searches.
|
|
si.scheduleChampionRebuild()
|
|
}
|
|
|
|
si.logger.Info("incremental refresh: complete", "applied", applied, "throughSeries", through)
|
|
}
|
|
|
|
// applyIncremental downloads one incremental dump, aggregates its listen
|
|
// counts, rolls release counts up to release groups, and applies the
|
|
// deltas atomically together with the high-water-mark advance.
|
|
func (si *SearchIndex) applyIncremental(
|
|
ctx context.Context, client *http.Client, dump incrementalDump,
|
|
) error {
|
|
start := time.Now()
|
|
|
|
stream := newResumableReader(ctx, client, dump.url, 0)
|
|
|
|
defer func() { _ = stream.Close() }()
|
|
|
|
counts, err := aggregateTarListens(ctx, stream)
|
|
if err != nil {
|
|
return fmt.Errorf("aggregate incremental %d: %w", dump.series, err)
|
|
}
|
|
|
|
rec, art, rel := splitCountsByKind(counts)
|
|
rg := si.rollupReleaseDeltas(rel)
|
|
|
|
if err := si.commitListenDeltas(dump.series, rec, art, rg); err != nil {
|
|
return err
|
|
}
|
|
|
|
si.logger.Info("incremental refresh: applied dump",
|
|
"series", dump.series,
|
|
"recordings", len(rec),
|
|
"artists", len(art),
|
|
"releaseGroups", len(rg),
|
|
"elapsed", time.Since(start).Round(time.Millisecond),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// splitCountsByKind separates an aggregated counts map into per-kind
|
|
// maps of canonical MBID string → delta.
|
|
func splitCountsByKind(counts map[mbidKey]uint32) (rec, art, rel map[string]uint32) {
|
|
rec = make(map[string]uint32)
|
|
art = make(map[string]uint32)
|
|
rel = make(map[string]uint32)
|
|
|
|
for k, v := range counts {
|
|
mbid := formatUUID(k[1:])
|
|
|
|
switch k[0] {
|
|
case countKindRecording:
|
|
rec[mbid] += v
|
|
case countKindArtist:
|
|
art[mbid] += v
|
|
case countKindRelease:
|
|
rel[mbid] += v
|
|
}
|
|
}
|
|
|
|
return rec, art, rel
|
|
}
|
|
|
|
// rollupReleaseDeltas maps per-release listen deltas to their release
|
|
// group via the release_to_rg table and sums per group. Releases not in
|
|
// the table (below the index floor, or unknown) contribute nothing.
|
|
func (si *SearchIndex) rollupReleaseDeltas(rel map[string]uint32) map[string]uint32 {
|
|
rg := make(map[string]uint32)
|
|
if len(rel) == 0 {
|
|
return rg
|
|
}
|
|
|
|
mbids := make([]string, 0, len(rel))
|
|
for m := range rel {
|
|
mbids = append(mbids, m)
|
|
}
|
|
|
|
for i := 0; i < len(mbids); i += releaseLookupBatch {
|
|
end := min(i+releaseLookupBatch, len(mbids))
|
|
batch := mbids[i:end]
|
|
|
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")
|
|
|
|
args := make([]any, len(batch))
|
|
for j, m := range batch {
|
|
args[j] = m
|
|
}
|
|
|
|
rows, err := si.db.QueryContext(
|
|
"SELECT release_mbid, rg_mbid FROM release_to_rg WHERE release_mbid IN ("+placeholders+")",
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
si.logger.Warn("incremental refresh: release_to_rg lookup failed", "error", err)
|
|
|
|
continue
|
|
}
|
|
|
|
for rows.Next() {
|
|
var relMBID, rgMBID string
|
|
if err := rows.Scan(&relMBID, &rgMBID); err == nil {
|
|
rg[rgMBID] += rel[relMBID]
|
|
}
|
|
}
|
|
|
|
_ = rows.Close()
|
|
}
|
|
|
|
return rg
|
|
}
|
|
|
|
// commitListenDeltas applies recording, artist, and release-group deltas
|
|
// to explore_index and advances the high-water-mark to series, all in a
|
|
// single transaction so the apply is crash-atomic and exactly-once.
|
|
func (si *SearchIndex) commitListenDeltas(
|
|
series int, rec, art, rg map[string]uint32,
|
|
) error {
|
|
tx, err := si.db.BeginTx()
|
|
if err != nil {
|
|
return fmt.Errorf("incremental tx: %w", err)
|
|
}
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.Exec(
|
|
// mbid and kind are stored the way explore_index stores them,
|
|
// so the join below is a plain equality rather than a
|
|
// conversion per row.
|
|
"CREATE TEMP TABLE IF NOT EXISTS incr_delta (mbid BLOB, kind INTEGER, delta INTEGER)",
|
|
); err != nil {
|
|
return fmt.Errorf("incremental temp table: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec("DELETE FROM incr_delta"); err != nil {
|
|
return fmt.Errorf("incremental temp reset: %w", err)
|
|
}
|
|
|
|
for _, kd := range []struct {
|
|
kind string
|
|
deltas map[string]uint32
|
|
}{
|
|
{"recording", rec},
|
|
{"artist", art},
|
|
{"release_group", rg},
|
|
} {
|
|
if err := insertDeltas(tx, kd.kind, kd.deltas); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Additive apply: bump popularity for every index row that has a
|
|
// matching delta. Rows with no delta are untouched; deltas with no
|
|
// matching row (entity not indexed) are ignored.
|
|
if _, err := tx.Exec(`
|
|
UPDATE explore_index
|
|
SET popularity = popularity + d.delta
|
|
FROM incr_delta d
|
|
WHERE d.mbid = explore_index.mbid
|
|
AND d.kind = explore_index.entity_type
|
|
`); err != nil {
|
|
return fmt.Errorf("incremental apply: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec("DELETE FROM incr_delta"); err != nil {
|
|
return fmt.Errorf("incremental temp cleanup: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(
|
|
"INSERT OR REPLACE INTO explore_index_meta (key, value) VALUES (?, ?)",
|
|
listensAppliedSeriesKey, strconv.Itoa(series),
|
|
); err != nil {
|
|
return fmt.Errorf("incremental advance high-water-mark: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("incremental commit: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// insertDeltas bulk-inserts a kind's deltas into the temp table.
|
|
func insertDeltas(tx *sql.Tx, kind string, deltas map[string]uint32) error {
|
|
if len(deltas) == 0 {
|
|
return nil
|
|
}
|
|
|
|
rowArgs := make([]any, 0, deltaInsertBatch*3)
|
|
pending := 0
|
|
|
|
flush := func() error {
|
|
if pending == 0 {
|
|
return nil
|
|
}
|
|
|
|
values := strings.TrimSuffix(strings.Repeat("(?,?,?),", pending), ",")
|
|
query := "INSERT INTO incr_delta (mbid, kind, delta) VALUES " + values
|
|
|
|
if _, err := tx.Exec(query, rowArgs...); err != nil {
|
|
return fmt.Errorf("incremental insert deltas: %w", err)
|
|
}
|
|
|
|
rowArgs = rowArgs[:0]
|
|
pending = 0
|
|
|
|
return nil
|
|
}
|
|
|
|
code := entityCode(kind)
|
|
|
|
for mbid, d := range deltas {
|
|
rowArgs = append(rowArgs, mbidBytes(mbid), code, int64(d))
|
|
pending++
|
|
|
|
if pending >= deltaInsertBatch {
|
|
if err := flush(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return flush()
|
|
}
|
|
|
|
// aggregateTarListens streams a tar of parquet listen members and sums
|
|
// the per-entity listen counts. Unlike the full-dump path, the whole
|
|
// (small) incremental is aggregated in RAM with no checkpointing.
|
|
func aggregateTarListens(ctx context.Context, r io.Reader) (map[mbidKey]uint32, error) {
|
|
buffered := bufio.NewReaderSize(r, 1<<20)
|
|
tr := tar.NewReader(buffered)
|
|
counts := make(map[mbidKey]uint32, 1<<18)
|
|
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("incremental tar: %w", err)
|
|
}
|
|
|
|
if hdr.Typeflag != tar.TypeReg || !strings.HasSuffix(hdr.Name, ".parquet") {
|
|
continue
|
|
}
|
|
|
|
if hdr.Size > maxParquetMemberSize {
|
|
return nil, fmt.Errorf(
|
|
"%w: parquet member %s is %d bytes", ErrDumpFormat, hdr.Name, hdr.Size,
|
|
)
|
|
}
|
|
|
|
buf := make([]byte, hdr.Size)
|
|
if _, err := io.ReadFull(tr, buf); err != nil {
|
|
return nil, fmt.Errorf("incremental member read: %w", err)
|
|
}
|
|
|
|
deltas, err := parseListenParquet(buf)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("incremental parse: %w", err)
|
|
}
|
|
|
|
for k, v := range deltas {
|
|
counts[k] += v
|
|
}
|
|
}
|
|
|
|
return counts, nil
|
|
}
|
|
|
|
// discoverIncrementalDumps lists the incremental directory and returns
|
|
// the spark-dump URLs for every dump with a series greater than
|
|
// sinceSeries, sorted ascending so they apply in chronological order.
|
|
func discoverIncrementalDumps(
|
|
ctx context.Context, client *http.Client, baseURL string, sinceSeries int,
|
|
) ([]incrementalDump, error) {
|
|
hrefs, err := listHrefs(ctx, client, baseURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var dumps []incrementalDump
|
|
|
|
for _, h := range hrefs {
|
|
dir := trimTrailingSlash(h)
|
|
if !incrementalDirRe.MatchString(dir) {
|
|
continue
|
|
}
|
|
|
|
series, ok := parseDumpSeries(dir)
|
|
if !ok || series <= sinceSeries {
|
|
continue
|
|
}
|
|
|
|
dirURL := baseURL + dir + "/"
|
|
|
|
files, err := listHrefs(ctx, client, dirURL)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, f := range files {
|
|
if incrementalFileRe.MatchString(f) {
|
|
dumps = append(dumps, incrementalDump{series: series, url: dirURL + f})
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Slice(dumps, func(i, j int) bool { return dumps[i].series < dumps[j].series })
|
|
|
|
return dumps, nil
|
|
}
|
|
|
|
// metaInt reads an integer-valued explore_index_meta key.
|
|
func (si *SearchIndex) metaInt(key string) (int, bool) {
|
|
rows, err := si.db.QueryContext("SELECT value FROM explore_index_meta WHERE key = ?", key)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
if !rows.Next() {
|
|
return 0, false
|
|
}
|
|
|
|
var v string
|
|
if err := rows.Scan(&v); err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
return n, true
|
|
}
|
|
|
|
// refreshedWithin reports whether the incremental refresh last ran less
|
|
// than d ago.
|
|
func (si *SearchIndex) refreshedWithin(d time.Duration) bool {
|
|
rows, err := si.db.QueryContext(
|
|
"SELECT value FROM explore_index_meta WHERE key = ?", listensCatchupTsKey,
|
|
)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
if !rows.Next() {
|
|
return false
|
|
}
|
|
|
|
var v string
|
|
if err := rows.Scan(&v); err != nil {
|
|
return false
|
|
}
|
|
|
|
t, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return time.Since(t) < d
|
|
}
|