diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 5b6c765..e7dc1ba 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -331,12 +331,13 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { "recordings", len(result.Recordings), ) - // Phases 2+3 are expensive (3+ LB API calls through the rate - // limiter). Skip them when the local index is ready — it - // already carries popularity data and covers the cross-reference - // use case. Only run as fallback during first launch before - // the index is built. - if !e.index.IsReady() { + // Phases 2+3: when the index is ready, use cached popularity + // from the index to rerank MB results (no API calls). + // When the index isn't ready, fall back to live LB API calls. + if e.index.IsReady() { + // Phase 2 (lite): rerank MB results using index popularity. + e.boostWithIndexPopularity(&result) + } else { // Phase 2: LB popularity lookups (3 POST calls, rate-limited). e.boostWithPopularity(&result) @@ -708,8 +709,8 @@ func filterAndCap(result *MBSearchResult) { const ( // Blending weights for final score. - relevanceWeight = 0.6 - popularityWeight = 0.4 + relevanceWeight = 0.4 + popularityWeight = 0.6 // mbSearchLimit is passed to each MB search call. Slightly // larger than maxResults to allow headroom for filtering. @@ -723,6 +724,45 @@ const ( minBlendedScore = 25 ) +// boostWithIndexPopularity reranks MB search results using +// popularity data from the local search index. No API calls — +// just SQLite lookups. This is the fast path used when the index +// is ready. +func (e *Service) boostWithIndexPopularity(result *MBSearchResult) { + // Look up popularity for all artist MBIDs. + artistPop := make(map[string]int, len(result.Artists)) + + for _, a := range result.Artists { + if pop := e.index.GetPopularity(a.MBID); pop > 0 { + artistPop[a.MBID] = pop + } + } + + rerankArtists(result.Artists, artistPop) + + // Look up popularity for release groups. + rgPop := make(map[string]int, len(result.ReleaseGroups)) + + for _, rg := range result.ReleaseGroups { + if pop := e.index.GetPopularity(rg.MBID); pop > 0 { + rgPop[rg.MBID] = pop + } + } + + rerankReleaseGroups(result.ReleaseGroups, rgPop) + + // Look up popularity for recordings. + recPop := make(map[string]int, len(result.Recordings)) + + for _, r := range result.Recordings { + if pop := e.index.GetPopularity(r.MBID); pop > 0 { + recPop[r.MBID] = pop + } + } + + rerankRecordings(result.Recordings, recPop) +} + // boostWithPopularity fetches ListenBrainz listen counts for all // entities in result and re-sorts each slice using a blended score // of MB text relevance + log-scaled popularity. Modifies result diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go index 3d53393..3380e69 100644 --- a/backend/explore/searchindex.go +++ b/backend/explore/searchindex.go @@ -190,6 +190,33 @@ func (si *SearchIndex) IsReady() bool { return si.ready } +// GetPopularity returns the cached popularity (listen count) for +// the given MBID from the local index. Returns 0 if not found. +func (si *SearchIndex) GetPopularity(mbid string) int { + if mbid == "" { + return 0 + } + + rows, err := si.db.QueryContext( + "SELECT popularity FROM explore_index WHERE mbid = ? LIMIT 1", + mbid, + ) + if err != nil { + return 0 + } + + defer func() { _ = rows.Close() }() + + if rows.Next() { + var pop int + if err := rows.Scan(&pop); err == nil { + return pop + } + } + + return 0 +} + // AddFromCache inserts entries from a cached discography browse // into the search index (Tier 5: organic growth). Called when a // user views an artist page and the discography is fetched. @@ -254,7 +281,7 @@ func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult { FROM explore_index i JOIN explore_index_fts f ON f.rowid = i.id WHERE explore_index_fts MATCH ? - ORDER BY bm25(explore_index_fts, 3.0, 1.0, 0.5) - (ln(i.popularity + 1) * 0.5) + ORDER BY bm25(explore_index_fts, 3.0, 1.0, 0.5) - (ln(i.popularity + 1) * 1.5) LIMIT ? `, ftsQuery, limit) if err != nil {