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:
+128
-1
@@ -2,6 +2,7 @@ package explore
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -20,6 +21,7 @@ type Service struct {
|
|||||||
mb *MusicBrainzClient
|
mb *MusicBrainzClient
|
||||||
lb *ListenBrainzClient
|
lb *ListenBrainzClient
|
||||||
cache *Cache
|
cache *Cache
|
||||||
|
index *SearchIndex
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
}
|
}
|
||||||
@@ -32,6 +34,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
|
|||||||
limiter := NewRateLimiter()
|
limiter := NewRateLimiter()
|
||||||
mb := NewMusicBrainzClient(cache, logger.WithGroup("musicbrainz"))
|
mb := NewMusicBrainzClient(cache, logger.WithGroup("musicbrainz"))
|
||||||
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
|
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
|
||||||
|
index := NewSearchIndex(db, lb, logger.WithGroup("search-index"))
|
||||||
|
|
||||||
logger.Info("explore service created")
|
logger.Info("explore service created")
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
|
|||||||
mb: mb,
|
mb: mb,
|
||||||
lb: lb,
|
lb: lb,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
|
index: index,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
}
|
}
|
||||||
@@ -48,6 +52,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
|
|||||||
// OnStartup after the Wails runtime is initialised.
|
// OnStartup after the Wails runtime is initialised.
|
||||||
func (e *Service) SetContext(ctx context.Context) {
|
func (e *Service) SetContext(ctx context.Context) {
|
||||||
e.ctx = ctx
|
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) {
|
func (e *Service) Search(query string) (*MBSearchResult, error) {
|
||||||
e.logger.Info("search started", "query", query)
|
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).
|
// Phase 1: concurrent MB search (3 goroutines, library-limited).
|
||||||
var (
|
var (
|
||||||
result MBSearchResult
|
result MBSearchResult
|
||||||
@@ -241,7 +249,10 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
|
|||||||
// missed (e.g. "for you tatsuro" → FOR YOU by 山下達郎).
|
// missed (e.g. "for you tatsuro" → FOR YOU by 山下達郎).
|
||||||
e.crossReferenceAlbums(query, &result)
|
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)
|
filterAndCap(&result)
|
||||||
|
|
||||||
e.logger.Info("search completed",
|
e.logger.Info("search completed",
|
||||||
@@ -433,6 +444,122 @@ func fuzzyMatchRatio(query, title string) float64 {
|
|||||||
return float64(hits) / float64(len(queryWords))
|
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 0–100 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
|
// Filtering and capping
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user