From d47270e0b877d1624d13a65c41295978b10d5981 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sun, 29 Mar 2026 13:04:21 -0400 Subject: [PATCH] =?UTF-8?q?perf:=20lightweight=20post-scan=20indexing=20?= =?UTF-8?q?=E2=80=94=20only=20index=20new=20artists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a library scan completes, OnAllScansComplete now calls IndexNewArtists() instead of StartIndexBuild(). This skips the full tier pipeline (sitewide top artists, similar artists, freshness checks) and only indexes library artists whose MBIDs are not yet in the search index. Flow after scan: 1. Query indexed artist MBIDs (fast, in-memory set) 2. Query library artist MBIDs 3. Diff → only new artists 4. Fetch discographies + images for new artists only The full StartIndexBuild() still runs on initial launch (when SoftScanAllLibraries finds no work to do) to handle the tier pipeline with freshness-based refresh. But adding 5 new albums to your library no longer triggers a 60-minute index rebuild. --- backend/app.go | 10 ++-- backend/explore/explore.go | 6 +++ backend/explore/searchindex.go | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/backend/app.go b/backend/app.go index d4befcc..b72d72f 100644 --- a/backend/app.go +++ b/backend/app.go @@ -232,13 +232,9 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { yj.library.SetScanHooks(library.ScanHooks{ ResolvePhantoms: yj.playlist.ResolvePhantomTracksAfterScan, OnAllScansComplete: func() { - // Start the index build after scans finish. - // Tiers 2-4 are incremental — filterUnindexed - // already skips artists that are already indexed, - // so new library artists with freshly-populated - // MBIDs get picked up without invalidating the - // entire discography cache. - yj.explore.StartIndexBuild() + // Only index artists that are new since the last + // build — don't re-run the full tier pipeline. + yj.explore.IndexNewArtists() }, }) diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 958d69e..0d26264 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -72,6 +72,12 @@ func (e *Service) StartIndexBuild() { e.index.StartBuild(e.ctx) } +// IndexNewArtists indexes only library artists not yet in the search +// index. Lightweight post-scan path — skips the full tier machinery. +func (e *Service) IndexNewArtists() { + e.index.IndexNewArtists(e.ctx) +} + // StopIndexBuild cancels the background search index build. // Call before a full rescan to free the DB for the scan. func (e *Service) StopIndexBuild() { diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go index 02eb29d..8604ed5 100644 --- a/backend/explore/searchindex.go +++ b/backend/explore/searchindex.go @@ -134,6 +134,103 @@ func NewSearchIndex( } } +// IndexNewArtists indexes only library artists that are not yet in the +// search index. This is the lightweight post-scan path — no tier +// machinery, no freshness checks, no sitewide/similar artist logic. +// Just finds library artists with MBIDs missing from the index and +// fetches their discographies + images. +func (si *SearchIndex) IndexNewArtists(ctx context.Context) { + si.mu.Lock() + if si.cancel != nil { + // Full build already running — it will pick up new artists. + si.mu.Unlock() + + return + } + + si.done = make(chan struct{}) + si.mu.Unlock() + + buildCtx, cancel := context.WithCancel(ctx) + + si.mu.Lock() + si.cancel = cancel + si.mu.Unlock() + + go func() { + defer func() { + si.mu.Lock() + si.cancel = nil + si.mu.Unlock() + + close(si.done) + }() + + si.indexNewLibraryArtists(buildCtx) + }() +} + +// indexNewLibraryArtists finds library artists with MBIDs that are not +// in the index and fetches their discographies. +func (si *SearchIndex) indexNewLibraryArtists(ctx context.Context) { + indexed := si.indexedArtistMBIDs() + libraryMBIDs := si.getLibraryArtistMBIDs() + + var newArtists []lbSitewideArtist + + for _, mbid := range libraryMBIDs { + if !indexed[mbid] { + // Look up the artist name from the DB. + var name string + + rows, err := si.db.QueryContext( + "SELECT name FROM artists WHERE mbid = ? LIMIT 1", mbid, + ) + if err != nil { + continue + } + + if !rows.Next() { + _ = rows.Close() + + continue + } + + if err := rows.Scan(&name); err != nil { + _ = rows.Close() + + continue + } + + _ = rows.Close() + + newArtists = append(newArtists, lbSitewideArtist{ + ArtistMBID: mbid, + ArtistName: name, + }) + } + } + + if len(newArtists) == 0 { + si.logger.Info("search index: no new library artists to index") + + return + } + + si.logger.Info("search index: indexing new library artists", + "count", len(newArtists), + ) + + indexLimiter := NewRateLimiterN(indexerRate) + indexLB := NewListenBrainzClient(indexLimiter, si.lb.cache, si.logger.WithGroup("indexer")) + + si.indexArtistDiscographies(ctx, indexLB, newArtists, "new-artists") + + si.logger.Info("search index: new library artists indexed", + "count", len(newArtists), + ) +} + // StartBuild launches the background index build goroutine. // Returns immediately. func (si *SearchIndex) StartBuild(ctx context.Context) {