mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* fix(instant-mix): top short mixes up instead of returning what the first source found
SimilarSongs returned the first non-empty source's tracks, however few. For a
thinly-represented artist that meant a 3-track mix no matter the requested count:
the artist agent found nothing, the similar-artists fallback matched 3 library
tracks, and `len(res) > 0` kept seed-track sampling from ever running.
Clients treat that as a failed mix and retry with a bigger limit forever. Finamp
cycles limit 34 through 472 and starts over, ~1 request every 2s indefinitely,
each one re-hitting Last.fm, Deezer and AudioMuse.
Sources are now chained rather than raced: each one tops the mix up until it
holds count tracks, so the agent's picks, the similar-artists fallback and
seed-track sampling all contribute instead of the first one winning outright.
* refactor(external): move similar-songs code to its own file
provider.go held two distinct concerns: artist/album external metadata and the
similar-songs mix pipeline. The mix code was already one contiguous block, and
maxSeeds, maxSimilarSongs and dedupByID were used by nothing else.
Moved SimilarSongs and its helpers to provider_similarsongs.go, matching the
existing provider_similarsongs_test.go. Pure code motion: the moved block is
byte-for-byte unchanged and provider.go has no additions, only deletions.
* fix(instant-mix): dedup before deciding a mix is full
topUp measured res before deduplicating it. Matcher.MatchSongs deliberately
re-emits a library track when the same input song repeats, and the similar-artists
fallback can reach one track through several artists, so len(res) could equal count
while holding fewer unique tracks. That returned a mix with duplicates in it and
stopped the top-up early; the caller then deduplicated and handed back a short mix,
which is the client retry loop this branch set out to fix.
Deduplicate first, so the length check counts what the client will actually receive.
* perf(instant-mix): skip a fallback once the mix is already full
The artist path nested one topUp inside another, so the inner one measured only
similarSongsFallback's own result against the full count. With 49 agent matches and
one fallback match for count=50 the mix was already full, yet seed-track sampling
still ran and fired up to five GetSimilarSongsByTrack calls whose results the outer
topUp then truncated away.
topUp now takes the sources as a variadic list and re-checks the accumulated mix
before each one, so a later, costlier source only runs while the mix is still short.
That also flattens the artist case: the agent, the similar-artists fallback and
seed-track sampling are now three peers in one chain instead of two nested calls.
* fix(instant-mix): count distinct tracks when picking the fallback mix
similarSongsFallback stopped after count picks from the weighted chooser, but a
track can sit in that chooser once per artist listing it in their top songs, and
Pick removes the entry it returns. Repeats therefore consumed pick slots and left
unique candidates stranded, so the batch could come back short of count. On the
track path this is the only source, so that short mix reached the client and kept
the retry loop alive.
Track the ids already picked and keep drawing until count distinct tracks are held
or the chooser is empty.
* fix(instant-mix): match the whole agent response before trimming
MatchSongs was capped at count, and it re-emits a track when the same song repeats,
so [A, A, B] with count=2 returned [A, A] and never reached B. topUp then shrank
that to [A] and, with an empty or overlapping fallback, the mix stayed short even
though B had been available all along. seedMix already matched its full merged set
for this reason; mixFromAgent now does the same and leaves the trim to topUp.
Also drop the capacity hint on the picked-ids map. It was sized from the caller's
count, which CodeQL flags as an allocation sized by user input (go/uncontrolled-
allocation-size). SimilarSongs clamps count to maxSimilarSongs long before this
point, so the hint bought nothing worth the alert.
* refactor(instant-mix): tidy the mix chain and its specs
Quality pass over the new code, no behaviour change:
- topUp: drop the first-vs-last error bookkeeping (the value is only read when the
mix is empty, so the distinction is unobservable) and the redundant nil guard
(dedupByID returns nil for an empty result, so both branches already agreed).
- mixFromAgent: assign through the if-scoped err instead of a second error name.
- Hoist the similar-artists fallback closure written verbatim in two switch arms.
- Use map[string]struct{} in the pick loop, matching dedupByID in the same file.
- Trim three comments back within budget; two restated the line below them and one
carried commit-message rationale.
- Tests: add an ids() helper for the ID assertion repeated seven times, and fold
the track-entity stub block copied into three specs into stubTrackEntity. The
block hard-coded .Twice() on GetEntityByID, which pinned an implementation
detail no spec asserts.
* revert(instant-mix): inline the similar-artists fallback closure again
Hoisting it to a shared artistFallback var moved the call away from the arm that
uses it and saved nothing: each arm reads better spelling out its own sources.
666 lines
18 KiB
Go
666 lines
18 KiB
Go
package external
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"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/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, artistId string, count int) (model.MediaFiles, 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 = new(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 = new(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) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) {
|
|
artist, err := e.findArtist(ctx, artistName, id)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Error(ctx, "Artist not found", "name", artistName, "id", id, err)
|
|
return nil, nil
|
|
}
|
|
|
|
log.Error(ctx, "Failure occurred when trying to fetch artist", "name", artistName, "id", id, err)
|
|
return nil, err
|
|
}
|
|
|
|
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 top songs with the queried artist. A song with no artists, or whose first credit the
|
|
// agent left unnamed, is attributed to the queried artist. A first credit that already names an
|
|
// artist is left as-is: it may be a different (e.g. featured) artist, so stamping the queried
|
|
// MBID onto it would create a false name+MBID pairing.
|
|
for i := range songs {
|
|
switch {
|
|
case len(songs[i].Artists) == 0:
|
|
songs[i].Artists = []agents.Artist{{Name: artistName, MBID: artist.MbzArtistID}}
|
|
case songs[i].Artists[0].Name == "":
|
|
songs[i].Artists[0].Name = artistName
|
|
if songs[i].Artists[0].MBID == "" {
|
|
songs[i].Artists[0].MBID = artist.MbzArtistID
|
|
}
|
|
}
|
|
}
|
|
|
|
mfs, err := e.matcher.MatchSongs(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' ")
|
|
}
|
|
|
|
// callGetImage populates artist's image URLs. A transient agent failure is
|
|
// returned as-is; a definitive "no image" is normalized to model.ErrNotFound.
|
|
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) error {
|
|
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
|
if err != nil {
|
|
if errors.Is(err, agents.ErrNotFound) {
|
|
return model.ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) {
|
|
if id != "" {
|
|
artist, err := e.ds.Artist(ctx).Get(id)
|
|
if err == nil {
|
|
return &auxArtist{Artist: *artist}, nil
|
|
}
|
|
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Warn(ctx, "Could not find artist by id", "id", id, err)
|
|
} else {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if artistName == "" {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|