4ddb0774ec
* test(artwork): add benchmark helpers for generating test images * test(artwork): add image decode benchmarks for JPEG/PNG at various sizes * test(artwork): add image resize benchmarks for Lanczos at various sizes * test(artwork): add image encode benchmarks for JPEG quality levels and PNG * test(artwork): add full resize pipeline benchmark (decode+resize+encode) * test(artwork): add tag extraction benchmark for embedded art * test(cache): add file cache benchmarks for read, write, and concurrent access * test(artwork): add E2E benchmarks for artwork.Get with cache on/off and concurrency * fix(test): use absolute path for tag extraction benchmark fixture * test(artwork): add resize alternatives benchmark comparing resamplers * perf(artwork): switch to CatmullRom resampler and JPEG for square images Replace imaging.Lanczos with imaging.CatmullRom for image resizing (30% faster, indistinguishable quality at thumbnail sizes). Stop forcing PNG encoding for square images when the source is JPEG — JPEG is smaller and faster to encode. Square images from JPEG sources went from 52ms to 10ms (80% improvement). Add sync.Pool for encode buffers to reduce GC pressure under concurrent load. * perf(artwork): increase cache warmer concurrency from 2 to 4 workers Resize is CPU-bound, so more workers improve throughput on multi-core systems. Doubled worker count to better utilize available cores during background cache warming. * perf(artwork): switch to xdraw.ApproxBiLinear and always encode as JPEG Replace disintegration/imaging with golang.org/x/image/draw for image resizing. This eliminates ~92K allocations per resize (from imaging's internal goroutine parallelism) down to ~20, reducing GC pressure under concurrent load. Always encode resized artwork as JPEG regardless of source format, since cover art doesn't need transparency. This is ~5x faster than PNG encode and produces much smaller output (e.g. 18KB JPEG vs 124KB PNG). * perf(artwork): skip external API call when artist image URL is cached ArtistImage() was always calling the external agent (Spotify/Last.fm) to get the image URL, even when the artist already had URLs stored in the database. This caused every artist image request to block on an external API call, creating severe serialization when loading artist grids (5-20 seconds for the first page). Now use the stored URL directly when available. Artists with no stored URL still fetch synchronously. Background refresh via UpdateArtistInfo handles TTL-based URL updates. * perf(artwork): increase getCoverArt throttle from NumCPU/3 to NumCPU The previous default of max(2, NumCPU/3) was too aggressive for artist images which are I/O-bound (downloading from external CDNs), not CPU-bound. On an 8-core machine this meant only 2 concurrent requests, causing a staircase pattern where 12 images took ~2.4s wall-clock. Bumping to max(4, NumCPU) cuts wall-clock time by ~50% for artist image grids while still preventing unbounded concurrency for CPU-bound resizes. * perf(artwork): encode resized images as WebP instead of JPEG Switch from JPEG to WebP encoding for resized artwork using gen2brain/webp (libwebp via WASM, no CGo). WebP produces ~74% smaller output at the same quality with only ~25% slower full-pipeline encode time (cached, so only paid once per artwork+size). Use NRGBA image type to preserve alpha channel in WebP output, and transparent padding for square canvas instead of black. Also removes the disintegration/imaging dependency entirely by replacing imaging.Fill in playlist tile generation with a custom fillCenter function using xdraw.ApproxBiLinear. * perf(artwork): switch from ApproxBiLinear to BiLinear scaling for improved image processing Signed-off-by: Deluan <deluan@navidrome.org> * refactor(configuration): rename CoverJpegQuality to CoverArtQuality and update references Signed-off-by: Deluan <deluan@navidrome.org> * feat(artwork): add DevJpegCoverArt option to control JPEG encoding for cover art Signed-off-by: Deluan <deluan@navidrome.org> * fix(artwork): remove redundant transparent fill and handle encode errors in resizeImage Removed a no-op draw.Draw call that filled the NRGBA canvas with transparent pixels — NewNRGBA already zero-initializes to fully transparent. Also added an early return on encode failure to avoid allocating and copying potentially corrupt buffer data before returning the error. * fix(configuration): reorder default agents (deezer is faster) Signed-off-by: Deluan <deluan@navidrome.org> * fix(test): resolve dogsled lint warning in tag extraction benchmark Use all return values from runtime.Caller instead of discarding three with blank identifiers, which triggered the dogsled linter. * fix(artwork): revert cache key format Signed-off-by: Deluan <deluan@navidrome.org> * fix(configuration): remove deprecated CoverJpegQuality field and update references to CoverArtQuality Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
788 lines
22 KiB
Go
788 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/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
|
|
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) Provider {
|
|
e := &provider{ds: ds, ag: agents}
|
|
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.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
|
|
}
|
|
|
|
// Use already-stored image URL if available, avoiding expensive external API calls.
|
|
// If the info is expired, the background refresh (via UpdateArtistInfo/artistQueue) will update it.
|
|
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()
|
|
}
|
|
|
|
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.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
|
|
}
|
|
}
|