mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-02 07:01:36 +00:00
* refactor: extract matchSongsToLibrary to core/matcher package Move the song-to-library matching algorithm from core/external into its own core/matcher package. The Matcher struct exposes a single public method MatchSongsToLibrary that implements a multi-phase matching algorithm (ID > MBID > ISRC > fuzzy title+artist). Includes pre-sanitization optimization for the fuzzy matching loop. No behavioral changes — the algorithm is identical to the version in core/external/provider_matching.go. * refactor: inject matcher.Matcher via Wire instead of creating it inline Add *matcher.Matcher as a dependency of external.NewProvider, wired via Google Wire. Update all provider test files to pass matcher.New(ds). This eliminates tight coupling so future consumers can reuse the matcher without depending on the external package. * refactor: remove old provider_matching files Delete core/external/provider_matching.go and its tests. All matching logic now lives in core/matcher/. * test(matcher): restore test coverage lost in extraction Port back 23 specs that existed in the old provider_matching_test.go but were dropped during the extraction. Covers specificity levels, fuzzy matching thresholds, fuzzy album matching, duration matching, and deduplication edge cases. * test(matcher): extract matchFieldInAnd/matchFieldInEq helpers The four inline mock.MatchedBy closures in setupAllPhaseExpectations all followed the same squirrel.And -> squirrel.Eq -> field-name-check pattern. Extract into two small helpers to reduce duplication and make the setup functions read as a concise list of phase expectations. * refactor(matcher): address PR #5348 review feedback - sanitizedTrack now holds *model.MediaFile instead of a value copy. Since MediaFile is a large struct (~74 fields), this avoids the per-track copy into sanitized[] and a second copy when findBestMatch assigns the winner. loadTracksByTitleAndArtist updated to iterate by index and pass &tracks[i]. - loadTracksByISRC now sorts results (starred desc, rating desc, year asc, compilation asc) so that when multiple library tracks share an ISRC the most relevant one is picked deterministically, matching the sort order already used by loadTracksByTitleAndArtist. - Restored the four worked examples (MBID Priority, ISRC Priority, Specificity Ranking, Fuzzy Title Matching) in the MatchSongsToLibrary godoc that were dropped during the extraction. - matcher_test.go: tests now enforce expectations via AssertExpectations in a DeferCleanup. The old setupAllPhaseExpectations helper was replaced with per-phase helpers (expectIDPhase/expectMBIDPhase/expectISRCPhase + allowOtherPhases) so each test deterministically verifies which matching phases fire. This surfaced (and fixes) a latent issue copilot flagged: the old .Once() expectations were not actually asserted, so tests would silently pass even when phases short-circuited unexpectedly.
796 lines
22 KiB
Go
796 lines
22 KiB
Go
package external
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/matcher"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils"
|
|
. "github.com/navidrome/navidrome/utils/gg"
|
|
"github.com/navidrome/navidrome/utils/random"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/navidrome/navidrome/utils/str"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
const (
|
|
maxSimilarArtists = 100
|
|
refreshDelay = 5 * time.Second
|
|
refreshTimeout = 15 * time.Second
|
|
refreshQueueLength = 2000
|
|
)
|
|
|
|
type Provider interface {
|
|
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
|
|
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
|
|
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
|
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
|
|
ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
|
AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
|
}
|
|
|
|
type provider struct {
|
|
ds model.DataStore
|
|
ag Agents
|
|
matcher *matcher.Matcher
|
|
artistQueue refreshQueue[auxArtist]
|
|
albumQueue refreshQueue[auxAlbum]
|
|
}
|
|
|
|
type auxAlbum struct {
|
|
model.Album
|
|
}
|
|
|
|
// Name returns the appropriate album name for external API calls
|
|
// based on the DevPreserveUnicodeInExternalCalls configuration option
|
|
func (a *auxAlbum) Name() string {
|
|
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
|
return a.Album.Name
|
|
}
|
|
return str.Clear(a.Album.Name)
|
|
}
|
|
|
|
type auxArtist struct {
|
|
model.Artist
|
|
}
|
|
|
|
// Name returns the appropriate artist name for external API calls
|
|
// based on the DevPreserveUnicodeInExternalCalls configuration option
|
|
func (a *auxArtist) Name() string {
|
|
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
|
return a.Artist.Name
|
|
}
|
|
return str.Clear(a.Artist.Name)
|
|
}
|
|
|
|
type Agents interface {
|
|
agents.AlbumInfoRetriever
|
|
agents.AlbumImageRetriever
|
|
agents.ArtistBiographyRetriever
|
|
agents.ArtistMBIDRetriever
|
|
agents.ArtistImageRetriever
|
|
agents.ArtistSimilarRetriever
|
|
agents.ArtistTopSongsRetriever
|
|
agents.ArtistURLRetriever
|
|
agents.SimilarSongsByTrackRetriever
|
|
agents.SimilarSongsByAlbumRetriever
|
|
agents.SimilarSongsByArtistRetriever
|
|
}
|
|
|
|
func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider {
|
|
e := &provider{ds: ds, ag: agents, matcher: m}
|
|
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
|
|
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
|
return e
|
|
}
|
|
|
|
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
|
var entity any
|
|
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
|
if err != nil {
|
|
return auxAlbum{}, err
|
|
}
|
|
|
|
var album auxAlbum
|
|
switch v := entity.(type) {
|
|
case *model.Album:
|
|
album.Album = *v
|
|
case *model.MediaFile:
|
|
return e.getAlbum(ctx, v.AlbumID)
|
|
default:
|
|
return auxAlbum{}, model.ErrNotFound
|
|
}
|
|
|
|
return album, nil
|
|
}
|
|
|
|
func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
|
|
album, err := e.getAlbum(ctx, id)
|
|
if err != nil {
|
|
log.Info(ctx, "Not found", "id", id)
|
|
return nil, err
|
|
}
|
|
|
|
updatedAt := V(album.ExternalInfoUpdatedAt)
|
|
albumName := album.Name()
|
|
if updatedAt.IsZero() {
|
|
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName)
|
|
album, err = e.populateAlbumInfo(ctx, album)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// If info is expired, trigger a populateAlbumInfo in the background
|
|
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
|
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
|
|
e.albumQueue.enqueue(&album)
|
|
}
|
|
|
|
return &album.Album, nil
|
|
}
|
|
|
|
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
|
|
start := time.Now()
|
|
albumName := album.Name()
|
|
info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
|
if errors.Is(err, agents.ErrNotFound) {
|
|
return album, nil
|
|
}
|
|
if err != nil {
|
|
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist,
|
|
"elapsed", time.Since(start), err)
|
|
return album, err
|
|
}
|
|
|
|
album.ExternalInfoUpdatedAt = P(time.Now())
|
|
album.ExternalUrl = info.URL
|
|
|
|
if info.Description != "" {
|
|
album.Description = info.Description
|
|
}
|
|
|
|
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
|
if err == nil && len(images) > 0 {
|
|
sort.Slice(images, func(i, j int) bool {
|
|
return images[i].Size > images[j].Size
|
|
})
|
|
|
|
album.LargeImageUrl = images[0].URL
|
|
|
|
if len(images) >= 2 {
|
|
album.MediumImageUrl = images[1].URL
|
|
}
|
|
|
|
if len(images) >= 3 {
|
|
album.SmallImageUrl = images[2].URL
|
|
}
|
|
}
|
|
|
|
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
|
|
if err != nil {
|
|
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
|
"elapsed", time.Since(start), err)
|
|
} else {
|
|
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
|
|
}
|
|
|
|
return album, nil
|
|
}
|
|
|
|
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) {
|
|
var entity any
|
|
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
|
if err != nil {
|
|
return auxArtist{}, err
|
|
}
|
|
|
|
var artist auxArtist
|
|
switch v := entity.(type) {
|
|
case *model.Artist:
|
|
artist.Artist = *v
|
|
case *model.MediaFile:
|
|
return e.getArtist(ctx, v.ArtistID)
|
|
case *model.Album:
|
|
return e.getArtist(ctx, v.AlbumArtistID)
|
|
default:
|
|
return auxArtist{}, model.ErrNotFound
|
|
}
|
|
return artist, nil
|
|
}
|
|
|
|
func (e *provider) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
|
artist, err := e.refreshArtistInfo(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = e.loadSimilar(ctx, &artist, similarCount, includeNotPresent)
|
|
return &artist.Artist, err
|
|
}
|
|
|
|
func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
|
|
artist, err := e.getArtist(ctx, id)
|
|
if err != nil {
|
|
return auxArtist{}, err
|
|
}
|
|
|
|
// If we don't have any info, retrieves it now
|
|
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
|
artistName := artist.Name()
|
|
if updatedAt.IsZero() {
|
|
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName)
|
|
artist, err = e.populateArtistInfo(ctx, artist)
|
|
if err != nil {
|
|
return auxArtist{}, err
|
|
}
|
|
}
|
|
|
|
// If info is expired, trigger a populateArtistInfo in the background
|
|
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
|
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
|
|
e.artistQueue.enqueue(&artist)
|
|
}
|
|
return artist, nil
|
|
}
|
|
|
|
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
|
|
start := time.Now()
|
|
// Get MBID first, if it is not yet available
|
|
artistName := artist.Name()
|
|
if artist.MbzArtistID == "" {
|
|
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName)
|
|
if mbid != "" && err == nil {
|
|
artist.MbzArtistID = mbid
|
|
}
|
|
}
|
|
|
|
// Call all registered agents and collect information
|
|
g := errgroup.Group{}
|
|
g.SetLimit(2)
|
|
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
|
|
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
|
|
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
|
|
g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
|
|
_ = g.Wait()
|
|
|
|
if utils.IsCtxDone(ctx) {
|
|
log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err())
|
|
return artist, ctx.Err()
|
|
}
|
|
|
|
artist.ExternalInfoUpdatedAt = P(time.Now())
|
|
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
|
if err != nil {
|
|
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
|
"elapsed", time.Since(start), err)
|
|
} else {
|
|
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
|
|
}
|
|
return artist, nil
|
|
}
|
|
|
|
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
|
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var songs []agents.Song
|
|
|
|
// Try entity-specific similarity first
|
|
switch v := entity.(type) {
|
|
case *model.MediaFile:
|
|
songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
|
|
case *model.Album:
|
|
songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
|
|
case *model.Artist:
|
|
songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
|
|
default:
|
|
log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity))
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
if err == nil && len(songs) > 0 {
|
|
return e.matcher.MatchSongsToLibrary(ctx, songs, count)
|
|
}
|
|
|
|
// Fallback to existing similar artists + top songs algorithm
|
|
return e.similarSongsFallback(ctx, id, count)
|
|
}
|
|
|
|
// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to
|
|
// get the artist of the given entity, retrieve similar artists, get their top songs, and pick
|
|
// a weighted random selection of songs to return as similar songs.
|
|
func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
|
artist, err := e.getArtist(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
|
|
if utils.IsCtxDone(ctx) {
|
|
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
|
|
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
|
|
if utils.IsCtxDone(ctx) {
|
|
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
|
return ctx.Err()
|
|
}
|
|
|
|
topCount := max(count, 20)
|
|
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
|
|
if err != nil {
|
|
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
|
return nil
|
|
}
|
|
|
|
weight := topCount * (4 + artistWeight)
|
|
for _, mf := range topSongs {
|
|
weightedSongs.Add(mf, weight)
|
|
weight -= 4
|
|
}
|
|
return nil
|
|
}
|
|
|
|
err = addArtist(artist.Artist, weightedSongs, count, 10)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, a := range artist.SimilarArtists {
|
|
err := addArtist(a, weightedSongs, count, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
var similarSongs model.MediaFiles
|
|
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
|
s, err := weightedSongs.Pick()
|
|
if err != nil {
|
|
log.Warn(ctx, "Error getting weighted song", err)
|
|
continue
|
|
}
|
|
similarSongs = append(similarSongs, s)
|
|
}
|
|
|
|
return similarSongs, nil
|
|
}
|
|
|
|
func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
|
artist, err := e.getArtist(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
imageUrl := artist.ArtistImageUrl()
|
|
if imageUrl == "" {
|
|
// No cached URL — must fetch from external source synchronously
|
|
e.callGetImage(ctx, e.ag, &artist)
|
|
if utils.IsCtxDone(ctx) {
|
|
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
|
|
return nil, ctx.Err()
|
|
}
|
|
imageUrl = artist.ArtistImageUrl()
|
|
} else {
|
|
// If cached info is expired, enqueue a background refresh so that config changes
|
|
// (e.g. disabling an agent) take effect without waiting for a full artist info refresh.
|
|
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
|
if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
|
log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt)
|
|
e.artistQueue.enqueue(&artist)
|
|
}
|
|
}
|
|
|
|
if imageUrl == "" {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
return url.Parse(imageUrl)
|
|
}
|
|
|
|
func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
|
album, err := e.getAlbum(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
albumName := album.Name()
|
|
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, agents.ErrNotFound):
|
|
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
|
|
return nil, model.ErrNotFound
|
|
case errors.Is(err, context.Canceled):
|
|
log.Debug(ctx, "GetAlbumImages call canceled", err)
|
|
default:
|
|
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if len(images) == 0 {
|
|
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
// Return the biggest image
|
|
var img agents.ExternalImage
|
|
for _, i := range images {
|
|
if img.Size <= i.Size {
|
|
img = i
|
|
}
|
|
}
|
|
if img.URL == "" {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
return url.Parse(img.URL)
|
|
}
|
|
|
|
func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
|
|
artist, err := e.findArtistByName(ctx, artistName)
|
|
if err != nil {
|
|
log.Error(ctx, "Artist not found", "name", artistName, err)
|
|
return nil, nil
|
|
}
|
|
|
|
songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, agents.ErrNotFound):
|
|
log.Trace(ctx, "TopSongs not found", "name", artistName)
|
|
return nil, model.ErrNotFound
|
|
case errors.Is(err, context.Canceled):
|
|
log.Debug(ctx, "TopSongs call canceled", err)
|
|
default:
|
|
log.Warn(ctx, "Error getting top songs from agent", "artist", artistName, err)
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
return songs, nil
|
|
}
|
|
|
|
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
|
|
artistName := artist.Name()
|
|
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
|
|
}
|
|
|
|
// Enrich songs with artist info if not already present (for top songs, we know the artist)
|
|
for i := range songs {
|
|
if songs[i].Artist == "" {
|
|
songs[i].Artist = artistName
|
|
}
|
|
if songs[i].ArtistMBID == "" {
|
|
songs[i].ArtistMBID = artist.MbzArtistID
|
|
}
|
|
}
|
|
|
|
mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(mfs) == 0 {
|
|
log.Debug(ctx, "No matching top songs found", "name", artistName)
|
|
} else {
|
|
log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs))
|
|
}
|
|
|
|
return mfs, nil
|
|
}
|
|
|
|
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
|
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
artist.ExternalUrl = artisURL
|
|
}
|
|
|
|
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
|
bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
bio = str.SanitizeText(bio)
|
|
bio = strings.ReplaceAll(bio, "\n", " ")
|
|
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
|
}
|
|
|
|
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
|
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size })
|
|
|
|
if len(images) >= 1 {
|
|
artist.LargeImageUrl = images[0].URL
|
|
}
|
|
if len(images) >= 2 {
|
|
artist.MediumImageUrl = images[1].URL
|
|
}
|
|
if len(images) >= 3 {
|
|
artist.SmallImageUrl = images[2].URL
|
|
}
|
|
}
|
|
|
|
func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
|
limit int, includeNotPresent bool) {
|
|
artistName := artist.Name()
|
|
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
|
|
if len(similar) == 0 || err != nil {
|
|
return
|
|
}
|
|
start := time.Now()
|
|
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
|
|
log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start))
|
|
if err != nil {
|
|
return
|
|
}
|
|
artist.SimilarArtists = sa
|
|
}
|
|
|
|
func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) {
|
|
var result model.Artists
|
|
var notPresent []string
|
|
|
|
// Load artists by ID (highest priority)
|
|
idMatches, err := e.loadArtistsByID(ctx, similar)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load artists by MBID (second priority)
|
|
mbidMatches, err := e.loadArtistsByMBID(ctx, similar, idMatches)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load artists by name (lowest priority, fallback)
|
|
nameMatches, err := e.loadArtistsByName(ctx, similar, idMatches, mbidMatches)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
count := 0
|
|
|
|
// Process the similar artists using priority: ID → MBID → Name
|
|
for _, s := range similar {
|
|
if count >= limit {
|
|
break
|
|
}
|
|
// Try ID match first
|
|
if s.ID != "" {
|
|
if artist, found := idMatches[s.ID]; found {
|
|
result = append(result, artist)
|
|
count++
|
|
continue
|
|
}
|
|
}
|
|
// Try MBID match second
|
|
if s.MBID != "" {
|
|
if artist, found := mbidMatches[s.MBID]; found {
|
|
result = append(result, artist)
|
|
count++
|
|
continue
|
|
}
|
|
}
|
|
// Fall back to name match
|
|
if artist, found := nameMatches[s.Name]; found {
|
|
result = append(result, artist)
|
|
count++
|
|
} else {
|
|
notPresent = append(notPresent, s.Name)
|
|
}
|
|
}
|
|
|
|
// Then fill up with non-present artists
|
|
if includeNotPresent && count < limit {
|
|
for _, s := range notPresent {
|
|
// Let the ID empty to indicate that the artist is not present in the DB
|
|
sa := model.Artist{Name: s}
|
|
result = append(result, sa)
|
|
|
|
count++
|
|
if count >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist) (map[string]model.Artist, error) {
|
|
var ids []string
|
|
for _, s := range similar {
|
|
if s.ID != "" {
|
|
ids = append(ids, s.ID)
|
|
}
|
|
}
|
|
matches := map[string]model.Artist{}
|
|
if len(ids) == 0 {
|
|
return matches, nil
|
|
}
|
|
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"artist.id": ids},
|
|
})
|
|
if err != nil {
|
|
return matches, err
|
|
}
|
|
for _, a := range res {
|
|
if _, ok := matches[a.ID]; !ok {
|
|
matches[a.ID] = a
|
|
}
|
|
}
|
|
return matches, nil
|
|
}
|
|
|
|
func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist) (map[string]model.Artist, error) {
|
|
var mbids []string
|
|
for _, s := range similar {
|
|
// Skip if already matched by ID
|
|
if s.ID != "" && idMatches[s.ID].ID != "" {
|
|
continue
|
|
}
|
|
if s.MBID != "" {
|
|
mbids = append(mbids, s.MBID)
|
|
}
|
|
}
|
|
matches := map[string]model.Artist{}
|
|
if len(mbids) == 0 {
|
|
return matches, nil
|
|
}
|
|
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"mbz_artist_id": mbids},
|
|
})
|
|
if err != nil {
|
|
return matches, err
|
|
}
|
|
for _, a := range res {
|
|
if id := a.MbzArtistID; id != "" {
|
|
if _, ok := matches[id]; !ok {
|
|
matches[id] = a
|
|
}
|
|
}
|
|
}
|
|
return matches, nil
|
|
}
|
|
|
|
func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist, mbidMatches map[string]model.Artist) (map[string]model.Artist, error) {
|
|
var names []string
|
|
for _, s := range similar {
|
|
// Skip if already matched by ID or MBID
|
|
if s.ID != "" && idMatches[s.ID].ID != "" {
|
|
continue
|
|
}
|
|
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
|
|
continue
|
|
}
|
|
names = append(names, s.Name)
|
|
}
|
|
matches := map[string]model.Artist{}
|
|
if len(names) == 0 {
|
|
return matches, nil
|
|
}
|
|
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
|
|
return squirrel.Like{"artist.name": name}
|
|
})
|
|
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Or(clauses),
|
|
})
|
|
if err != nil {
|
|
return matches, err
|
|
}
|
|
for _, a := range res {
|
|
if _, ok := matches[a.Name]; !ok {
|
|
matches[a.Name] = a
|
|
}
|
|
}
|
|
return matches, nil
|
|
}
|
|
|
|
func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
|
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Like{"artist.name": artistName},
|
|
Max: 1,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(artists) == 0 {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
return &auxArtist{Artist: artists[0]}, nil
|
|
}
|
|
|
|
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
|
|
var ids []string
|
|
for _, sa := range artist.SimilarArtists {
|
|
if sa.ID == "" {
|
|
continue
|
|
}
|
|
ids = append(ids, sa.ID)
|
|
}
|
|
|
|
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"artist.id": ids},
|
|
})
|
|
if err != nil {
|
|
log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err)
|
|
return err
|
|
}
|
|
|
|
// Use a map and iterate through original array, to keep the same order
|
|
artistMap := make(map[string]model.Artist)
|
|
for _, sa := range similar {
|
|
artistMap[sa.ID] = sa
|
|
}
|
|
|
|
var loaded model.Artists
|
|
for _, sa := range artist.SimilarArtists {
|
|
if len(loaded) >= count {
|
|
break
|
|
}
|
|
la, ok := artistMap[sa.ID]
|
|
if !ok {
|
|
if !includeNotPresent {
|
|
continue
|
|
}
|
|
la = sa
|
|
la.ID = ""
|
|
}
|
|
loaded = append(loaded, la)
|
|
}
|
|
artist.SimilarArtists = loaded
|
|
return nil
|
|
}
|
|
|
|
type refreshQueue[T any] chan<- *T
|
|
|
|
func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) (T, error)) refreshQueue[T] {
|
|
queue := make(chan *T, refreshQueueLength)
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(refreshDelay):
|
|
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
|
|
select {
|
|
case item := <-queue:
|
|
_, _ = processFn(ctx, *item)
|
|
cancel()
|
|
case <-ctx.Done():
|
|
cancel()
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return queue
|
|
}
|
|
|
|
func (q *refreshQueue[T]) enqueue(item *T) {
|
|
select {
|
|
case *q <- item:
|
|
default: // It is ok to miss a refresh request
|
|
}
|
|
}
|