feat: popularity-boosted search reranking via ListenBrainz

After MB search returns text-relevance-scored results, fetch bulk
popularity data from ListenBrainz (POST /1/popularity/{artist,
recording,release-group}) for all result MBIDs. Blend scores:

  final = 0.6 * mb_relevance + 0.4 * log10_popularity

Log-scale normalization ensures massive artists don't drown out
everything, but popular results rise above obscure exact matches.
Release groups (no MB score) sort by raw popularity.

Three LB POST calls run concurrently — each hits a different
endpoint. All are rate-limited and cached (24h TTL).

Example: searching 'tatsuro' now ranks Tatsuro Yamashita (2.5M LB
listens, score 97) above 'tatsuro' vocaloid producer (4 listens,
score 64) despite the latter being an exact name match on MB.
This commit is contained in:
2026-03-24 22:07:16 -04:00
parent 4ddd252e1e
commit 176ac26f91
2 changed files with 382 additions and 8 deletions
+196 -3
View File
@@ -3,6 +3,8 @@ package explore
import (
"context"
"log/slog"
"math"
"sort"
"sync"
"yellowjacket/backend/database"
@@ -127,12 +129,17 @@ func (e *Service) CoverArtGroupURL(releaseGroupMBID string) string {
}
// Search concurrently queries MusicBrainz for artists, release
// groups, and recordings matching the query, returning aggregated
// results in a single round-trip. If any sub-search fails the
// error is logged and the remaining results are still returned.
// groups, and recordings matching the query, then boosts results
// using ListenBrainz popularity data. The final score blends
// text relevance (60%) with log-scaled listen counts (40%).
//
// If any sub-search or popularity lookup fails the error is logged
// and the remaining results are still returned — popularity
// failures degrade to MB-only ordering.
func (e *Service) Search(query string) (*MBSearchResult, error) {
e.logger.Info("search started", "query", query)
// Phase 1: concurrent MB search (3 goroutines, library-limited).
var (
result MBSearchResult
mu sync.Mutex
@@ -216,6 +223,18 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
wg.Wait()
e.logger.Info("search MB complete",
"query", query,
"artists", len(result.Artists),
"releaseGroups", len(result.ReleaseGroups),
"recordings", len(result.Recordings),
)
// Phase 2: concurrent LB popularity lookups (3 goroutines,
// rate-limited). Each hits a different endpoint so they can
// overlap on different rate-limiter tokens.
e.boostWithPopularity(&result)
e.logger.Info("search completed",
"query", query,
"artists", len(result.Artists),
@@ -225,3 +244,177 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
return &result, nil
}
// ---------------------------------------------------------------------------
// Popularity-boosted reranking
// ---------------------------------------------------------------------------
const (
// Blending weights for final score.
relevanceWeight = 0.6
popularityWeight = 0.4
)
// 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
// in place. Failures are logged and degrade to MB-only ordering.
func (e *Service) boostWithPopularity(result *MBSearchResult) {
// Collect MBIDs per entity type.
artistMBIDs := make([]string, len(result.Artists))
for i, a := range result.Artists {
artistMBIDs[i] = a.MBID
}
recordingMBIDs := make([]string, len(result.Recordings))
for i, r := range result.Recordings {
recordingMBIDs[i] = r.MBID
}
rgMBIDs := make([]string, len(result.ReleaseGroups))
for i, rg := range result.ReleaseGroups {
rgMBIDs[i] = rg.MBID
}
// Fetch popularity concurrently.
var (
artistPop map[string]int
recordingPop map[string]int
rgPop map[string]int
wg sync.WaitGroup
)
wg.Add(3) //nolint:mnd
go func() {
defer wg.Done()
pop, err := e.lb.ArtistPopularity(e.ctx, artistMBIDs)
if err != nil {
e.logger.Warn("popularity lookup failed", "entity", "artist", "error", err)
return
}
artistPop = pop
}()
go func() {
defer wg.Done()
pop, err := e.lb.RecordingPopularity(e.ctx, recordingMBIDs)
if err != nil {
e.logger.Warn("popularity lookup failed", "entity", "recording", "error", err)
return
}
recordingPop = pop
}()
go func() {
defer wg.Done()
pop, err := e.lb.ReleaseGroupPopularity(e.ctx, rgMBIDs)
if err != nil {
e.logger.Warn("popularity lookup failed", "entity", "releaseGroup", "error", err)
return
}
rgPop = pop
}()
wg.Wait()
// Rerank each entity type.
rerankArtists(result.Artists, artistPop)
rerankRecordings(result.Recordings, recordingPop)
rerankReleaseGroups(result.ReleaseGroups, rgPop)
}
// rerankArtists sorts artists by blended score and updates their
// Score field to the new value (0100 scale).
func rerankArtists(artists []MBArtist, pop map[string]int) {
if len(artists) == 0 {
return
}
maxPop := maxListenCount(pop)
sort.SliceStable(artists, func(i, j int) bool {
si := blendedScore(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop)
sj := blendedScore(float64(artists[j].Score)/100.0, pop[artists[j].MBID], maxPop)
return si > sj
})
// Update Score field so the frontend's top-results section can
// use it directly.
maxPop2 := maxListenCount(pop)
for i := range artists {
s := blendedScore(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop2)
artists[i].Score = int(s * 100)
}
}
// rerankRecordings sorts recordings by blended score and updates
// their Score field.
func rerankRecordings(recordings []MBRecording, pop map[string]int) {
if len(recordings) == 0 {
return
}
maxPop := maxListenCount(pop)
sort.SliceStable(recordings, func(i, j int) bool {
si := blendedScore(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop)
sj := blendedScore(float64(recordings[j].Score)/100.0, pop[recordings[j].MBID], maxPop)
return si > sj
})
for i := range recordings {
s := blendedScore(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop)
recordings[i].Score = int(s * 100)
}
}
// rerankReleaseGroups sorts release groups by popularity only
// (they have no MB score field).
func rerankReleaseGroups(rgs []MBReleaseGroup, pop map[string]int) {
if len(rgs) == 0 || len(pop) == 0 {
return
}
sort.SliceStable(rgs, func(i, j int) bool {
return pop[rgs[i].MBID] > pop[rgs[j].MBID]
})
}
// blendedScore computes relevanceWeight*relevance + popularityWeight*logPop.
// relevance is 01. listenCount is raw; maxListenCount is the
// maximum in the result set (for normalization).
func blendedScore(relevance float64, listenCount, maxListenCount int) float64 {
if maxListenCount <= 0 {
return relevance
}
logPop := math.Log10(float64(listenCount)+1) / math.Log10(float64(maxListenCount)+1)
return relevanceWeight*relevance + popularityWeight*logPop
}
// maxListenCount returns the highest listen count in the map.
func maxListenCount(pop map[string]int) int {
maxVal := 0
for _, v := range pop {
if v > maxVal {
maxVal = v
}
}
return maxVal
}