feat: wire SearchIndex into Search() — Phase 0 index query + merge

Create SearchIndex in NewExploreService, start background build in
SetContext (on app startup). Search() now has 6 phases:

  Phase 0: query local FTS5 index (instant, no API calls)
  Phase 1: concurrent MB search
  Phase 2: LB popularity boost
  Phase 3: cross-reference artist discographies
  Phase 4: merge index hits (prepend new entries, dedup by MBID)
  Phase 5: filter and cap

Index hits for release groups/recordings not already in MB results
are prepended so popular albums surface even when MB search can't
find them. scalePopularity() maps raw listen counts to 0-100 scores
via log scaling for compatibility with the blended score system.
This commit is contained in:
2026-03-25 09:41:55 -04:00
parent bba4e1f3de
commit cf40909313
+128 -1
View File
@@ -2,6 +2,7 @@ package explore
import (
"context"
"encoding/json"
"log/slog"
"math"
"sort"
@@ -20,6 +21,7 @@ type Service struct {
mb *MusicBrainzClient
lb *ListenBrainzClient
cache *Cache
index *SearchIndex
logger *slog.Logger
ctx context.Context
}
@@ -32,6 +34,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
limiter := NewRateLimiter()
mb := NewMusicBrainzClient(cache, logger.WithGroup("musicbrainz"))
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
index := NewSearchIndex(db, lb, logger.WithGroup("search-index"))
logger.Info("explore service created")
@@ -39,6 +42,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
mb: mb,
lb: lb,
cache: cache,
index: index,
logger: logger,
ctx: context.Background(),
}
@@ -48,6 +52,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
// OnStartup after the Wails runtime is initialised.
func (e *Service) SetContext(ctx context.Context) {
e.ctx = ctx
e.index.StartBuild(ctx)
}
// ---------------------------------------------------------------------------
@@ -140,6 +145,9 @@ func (e *Service) CoverArtGroupURL(releaseGroupMBID string) string {
func (e *Service) Search(query string) (*MBSearchResult, error) {
e.logger.Info("search started", "query", query)
// Phase 0: query local popularity index (instant, no API calls).
indexHits := e.index.Search(query, 30) //nolint:mnd
// Phase 1: concurrent MB search (3 goroutines, library-limited).
var (
result MBSearchResult
@@ -241,7 +249,10 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
// missed (e.g. "for you tatsuro" → FOR YOU by 山下達郎).
e.crossReferenceAlbums(query, &result)
// Phase 4: filter low-scoring results and cap counts.
// Phase 4: merge local index hits into results, dedup by MBID.
mergeIndexHits(&result, indexHits)
// Phase 5: filter low-scoring results and cap counts.
filterAndCap(&result)
e.logger.Info("search completed",
@@ -433,6 +444,122 @@ func fuzzyMatchRatio(query, title string) float64 {
return float64(hits) / float64(len(queryWords))
}
// ---------------------------------------------------------------------------
// Index result merging
// ---------------------------------------------------------------------------
// mergeIndexHits injects local popularity index results into the
// MBSearchResult. Index hits for entity types not already present
// (by MBID) are prepended so they appear first — they come from
// the most popular albums/tracks globally and deserve prominence.
func mergeIndexHits(result *MBSearchResult, hits []SearchIndexResult) {
if len(hits) == 0 {
return
}
// Build MBID sets for existing results.
artistMBIDs := make(map[string]bool, len(result.Artists))
for _, a := range result.Artists {
artistMBIDs[a.MBID] = true
}
rgMBIDs := make(map[string]bool, len(result.ReleaseGroups))
for _, rg := range result.ReleaseGroups {
rgMBIDs[rg.MBID] = true
}
recMBIDs := make(map[string]bool, len(result.Recordings))
for _, r := range result.Recordings {
recMBIDs[r.MBID] = true
}
// Collect new entries from index.
var newArtists []MBArtist
var newRGs []MBReleaseGroup
var newRecs []MBRecording
for _, h := range hits {
switch h.EntityType {
case "artist":
if !artistMBIDs[h.MBID] {
newArtists = append(newArtists, MBArtist{
MBID: h.MBID,
Name: h.Title,
Score: scalePopularity(h.Popularity),
})
artistMBIDs[h.MBID] = true
}
case "release_group":
if !rgMBIDs[h.MBID] {
rg := MBReleaseGroup{
MBID: h.MBID,
Title: h.Title,
ArtistCredit: h.ArtistName,
}
// Extract type from extra_json if available.
if h.ExtraJSON != "" {
var extra map[string]string
if err := json.Unmarshal([]byte(h.ExtraJSON), &extra); err == nil {
rg.PrimaryType = extra["type"]
}
}
newRGs = append(newRGs, rg)
rgMBIDs[h.MBID] = true
}
case "recording":
if !recMBIDs[h.MBID] {
newRecs = append(newRecs, MBRecording{
MBID: h.MBID,
Title: h.Title,
ArtistCredit: h.ArtistName,
Score: scalePopularity(h.Popularity),
})
recMBIDs[h.MBID] = true
}
}
}
// Prepend index hits so they appear first.
if len(newArtists) > 0 {
result.Artists = append(newArtists, result.Artists...)
}
if len(newRGs) > 0 {
result.ReleaseGroups = append(newRGs, result.ReleaseGroups...)
}
if len(newRecs) > 0 {
result.Recordings = append(newRecs, result.Recordings...)
}
}
// scalePopularity maps a raw LB listen count to a 0100 score
// comparable with MB/blended scores. Uses log scaling.
func scalePopularity(listens int) int {
if listens <= 0 {
return 0
}
// log10(1M) ≈ 6, log10(10M) ≈ 7. Scale so 1M+ listens → ~80-100.
const scale = 15.0 // tuned so ~100K listens → ~75, ~1M → ~90
score := int(math.Log10(float64(listens)) * scale)
if score > 100 { //nolint:mnd
score = 100
}
return score
}
// ---------------------------------------------------------------------------
// Filtering and capping
// ---------------------------------------------------------------------------