Drop artists and recordings with blended score < 25 after popularity reranking. Cap each entity slice to 15 server-side. Request 20 from MB to allow filtering headroom. Reduces payload size and noise.
482 lines
13 KiB
Go
482 lines
13 KiB
Go
package explore
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"math"
|
||
"sort"
|
||
"sync"
|
||
|
||
"yellowjacket/backend/database"
|
||
)
|
||
|
||
// Service is the Wails-bound service for the explore feature.
|
||
// It owns the lifecycle of all explore-related components: the
|
||
// MusicBrainz client, ListenBrainz client, rate limiter, and
|
||
// response cache. Its exported methods form the binding surface
|
||
// that the frontend calls via generated TypeScript stubs.
|
||
type Service struct {
|
||
mb *MusicBrainzClient
|
||
lb *ListenBrainzClient
|
||
cache *Cache
|
||
logger *slog.Logger
|
||
ctx context.Context
|
||
}
|
||
|
||
// NewExploreService creates a Service backed by the given
|
||
// database. It instantiates the rate limiter, cache, MusicBrainz
|
||
// client, and ListenBrainz client internally.
|
||
func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
|
||
cache := NewCache(db, logger.WithGroup("cache"))
|
||
limiter := NewRateLimiter()
|
||
mb := NewMusicBrainzClient(cache, logger.WithGroup("musicbrainz"))
|
||
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
|
||
|
||
logger.Info("explore service created")
|
||
|
||
return &Service{
|
||
mb: mb,
|
||
lb: lb,
|
||
cache: cache,
|
||
logger: logger,
|
||
ctx: context.Background(),
|
||
}
|
||
}
|
||
|
||
// SetContext injects the Wails runtime context. Called from
|
||
// OnStartup after the Wails runtime is initialised.
|
||
func (e *Service) SetContext(ctx context.Context) {
|
||
e.ctx = ctx
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MusicBrainz search
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// SearchArtists queries MusicBrainz for artists matching the query.
|
||
func (e *Service) SearchArtists(query string) ([]MBArtist, error) {
|
||
return e.mb.SearchArtists(e.ctx, query, mbSearchLimit)
|
||
}
|
||
|
||
// SearchReleaseGroups queries MusicBrainz for release groups matching the query.
|
||
func (e *Service) SearchReleaseGroups(query string) ([]MBReleaseGroup, error) {
|
||
return e.mb.SearchReleaseGroups(e.ctx, query, mbSearchLimit)
|
||
}
|
||
|
||
// SearchRecordings queries MusicBrainz for recordings matching the query.
|
||
func (e *Service) SearchRecordings(query string) ([]MBRecording, error) {
|
||
return e.mb.SearchRecordings(e.ctx, query, mbSearchLimit)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MusicBrainz lookup
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// LookupArtist fetches a single MusicBrainz artist by MBID.
|
||
func (e *Service) LookupArtist(mbid string) (*MBArtist, error) {
|
||
return e.mb.LookupArtist(e.ctx, mbid)
|
||
}
|
||
|
||
// LookupReleaseGroup fetches a single MusicBrainz release group by MBID.
|
||
func (e *Service) LookupReleaseGroup(mbid string) (*MBReleaseGroup, error) {
|
||
return e.mb.LookupReleaseGroup(e.ctx, mbid)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MusicBrainz browse
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// BrowseReleaseGroups fetches release groups for a given artist MBID.
|
||
func (e *Service) BrowseReleaseGroups(artistMBID string) ([]MBReleaseGroup, error) {
|
||
return e.mb.BrowseReleaseGroups(e.ctx, artistMBID)
|
||
}
|
||
|
||
// BrowseReleases fetches releases for a given release group MBID.
|
||
func (e *Service) BrowseReleases(releaseGroupMBID string) ([]MBRelease, error) {
|
||
return e.mb.BrowseReleases(e.ctx, releaseGroupMBID)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ListenBrainz
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// TopRecordingsForArtist returns the most-listened recordings for an artist.
|
||
func (e *Service) TopRecordingsForArtist(artistMBID string) ([]LBTopRecording, error) {
|
||
return e.lb.TopRecordingsForArtist(e.ctx, artistMBID)
|
||
}
|
||
|
||
// SimilarArtists returns artists similar to the given artist MBID.
|
||
func (e *Service) SimilarArtists(artistMBID string) ([]LBSimilarArtist, error) {
|
||
return e.lb.SimilarArtists(e.ctx, artistMBID)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cover Art Archive
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// CoverArtURL returns the Cover Art Archive URL for a release's
|
||
// front cover at the default 250px size.
|
||
func (e *Service) CoverArtURL(releaseMBID string) string {
|
||
return CoverArtURL(releaseMBID)
|
||
}
|
||
|
||
// CoverArtGroupURL returns the Cover Art Archive URL for a release
|
||
// group's front cover at the default 250px size. This is the
|
||
// correct endpoint for search results, which return release group
|
||
// MBIDs rather than individual release MBIDs.
|
||
func (e *Service) CoverArtGroupURL(releaseGroupMBID string) string {
|
||
return CoverArtGroupURL(releaseGroupMBID)
|
||
}
|
||
|
||
// Search concurrently queries MusicBrainz for artists, release
|
||
// 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
|
||
wg sync.WaitGroup
|
||
)
|
||
|
||
type searchFunc struct {
|
||
name string
|
||
fn func()
|
||
}
|
||
|
||
searches := []searchFunc{
|
||
{
|
||
name: "artists",
|
||
fn: func() {
|
||
artists, err := e.mb.SearchArtists(e.ctx, query, mbSearchLimit)
|
||
if err != nil {
|
||
e.logger.Warn("search sub-call failed",
|
||
"entity", "artists",
|
||
"query", query,
|
||
"error", err,
|
||
)
|
||
|
||
return
|
||
}
|
||
|
||
mu.Lock()
|
||
result.Artists = artists
|
||
mu.Unlock()
|
||
},
|
||
},
|
||
{
|
||
name: "releaseGroups",
|
||
fn: func() {
|
||
rgs, err := e.mb.SearchReleaseGroups(e.ctx, query, mbSearchLimit)
|
||
if err != nil {
|
||
e.logger.Warn("search sub-call failed",
|
||
"entity", "releaseGroups",
|
||
"query", query,
|
||
"error", err,
|
||
)
|
||
|
||
return
|
||
}
|
||
|
||
mu.Lock()
|
||
result.ReleaseGroups = rgs
|
||
mu.Unlock()
|
||
},
|
||
},
|
||
{
|
||
name: "recordings",
|
||
fn: func() {
|
||
recs, err := e.mb.SearchRecordings(e.ctx, query, mbSearchLimit)
|
||
if err != nil {
|
||
e.logger.Warn("search sub-call failed",
|
||
"entity", "recordings",
|
||
"query", query,
|
||
"error", err,
|
||
)
|
||
|
||
return
|
||
}
|
||
|
||
mu.Lock()
|
||
result.Recordings = recs
|
||
mu.Unlock()
|
||
},
|
||
},
|
||
}
|
||
|
||
wg.Add(len(searches))
|
||
|
||
for _, s := range searches {
|
||
go func() {
|
||
defer wg.Done()
|
||
|
||
s.fn()
|
||
}()
|
||
}
|
||
|
||
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)
|
||
|
||
// Phase 3: filter low-scoring results and cap counts.
|
||
filterAndCap(&result)
|
||
|
||
e.logger.Info("search completed",
|
||
"query", query,
|
||
"artists", len(result.Artists),
|
||
"releaseGroups", len(result.ReleaseGroups),
|
||
"recordings", len(result.Recordings),
|
||
)
|
||
|
||
return &result, nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filtering and capping
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// filterAndCap removes low-scoring results and limits each entity
|
||
// slice to maxResults entries.
|
||
func filterAndCap(result *MBSearchResult) {
|
||
// Filter artists by minimum blended score.
|
||
if len(result.Artists) > 0 {
|
||
filtered := result.Artists[:0]
|
||
|
||
for _, a := range result.Artists {
|
||
if a.Score >= minBlendedScore {
|
||
filtered = append(filtered, a)
|
||
}
|
||
}
|
||
|
||
result.Artists = filtered
|
||
}
|
||
|
||
// Filter recordings by minimum blended score.
|
||
if len(result.Recordings) > 0 {
|
||
filtered := result.Recordings[:0]
|
||
|
||
for _, r := range result.Recordings {
|
||
if r.Score >= minBlendedScore {
|
||
filtered = append(filtered, r)
|
||
}
|
||
}
|
||
|
||
result.Recordings = filtered
|
||
}
|
||
|
||
// Cap each slice.
|
||
if len(result.Artists) > maxResults {
|
||
result.Artists = result.Artists[:maxResults]
|
||
}
|
||
|
||
if len(result.ReleaseGroups) > maxResults {
|
||
result.ReleaseGroups = result.ReleaseGroups[:maxResults]
|
||
}
|
||
|
||
if len(result.Recordings) > maxResults {
|
||
result.Recordings = result.Recordings[:maxResults]
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Popularity-boosted reranking
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const (
|
||
// Blending weights for final score.
|
||
relevanceWeight = 0.6
|
||
popularityWeight = 0.4
|
||
|
||
// mbSearchLimit is passed to each MB search call. Slightly
|
||
// larger than maxResults to allow headroom for filtering.
|
||
mbSearchLimit = 20
|
||
|
||
// maxResults caps each entity slice after filtering.
|
||
maxResults = 15
|
||
|
||
// minBlendedScore is the floor for artists and recordings
|
||
// after popularity reranking (0–100 scale).
|
||
minBlendedScore = 25
|
||
)
|
||
|
||
// 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 (0–100 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 0–1. 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
|
||
}
|