Add artistImageUrl available in getArtists endpoint

Also cache artist info in the DB for 1 hour
This commit is contained in:
Deluan 2020-10-30 16:08:43 -04:00
parent 7583ddac65
commit cfad35544b
15 changed files with 390 additions and 195 deletions

View File

@ -27,6 +27,8 @@ const (
RequestThrottleBacklogLimit = 100 RequestThrottleBacklogLimit = 100
RequestThrottleBacklogTimeout = time.Minute RequestThrottleBacklogTimeout = time.Minute
ArtistInfoTimeToLive = 1 * time.Hour
I18nFolder = "i18n" I18nFolder = "i18n"
SkipScanFile = ".ndignore" SkipScanFile = ".ndignore"

View File

@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/core/lastfm" "github.com/deluan/navidrome/core/lastfm"
"github.com/deluan/navidrome/core/spotify" "github.com/deluan/navidrome/core/spotify"
"github.com/deluan/navidrome/log" "github.com/deluan/navidrome/log"
@ -22,7 +23,7 @@ const placeholderArtistImageMediumUrl = "https://lastfm.freetls.fastly.net/i/u/1
const placeholderArtistImageLargeUrl = "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" const placeholderArtistImageLargeUrl = "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"
type ExternalInfo interface { type ExternalInfo interface {
ArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.ArtistInfo, 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) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
} }
@ -37,32 +38,74 @@ type externalInfo struct {
spf *spotify.Client spf *spotify.Client
} }
func (e *externalInfo) getArtist(ctx context.Context, id string) (artist *model.Artist, err error) { const UnavailableArtistID = "-1"
func (e *externalInfo) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
}
// If we have updated info, just return it
if time.Since(artist.ExternalInfoUpdatedAt) < consts.ArtistInfoTimeToLive {
log.Debug("Found cached ArtistInfo", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
err := e.loadSimilar(ctx, artist, includeNotPresent)
return artist, err
}
log.Debug("ArtistInfo not cached", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id)
// TODO Load from local: artist.jpg/png/webp, artist.json (with the remaining info)
var wg sync.WaitGroup
e.callArtistInfo(ctx, artist, &wg)
e.callArtistImages(ctx, artist, &wg)
e.callSimilarArtists(ctx, artist, count, &wg)
wg.Wait()
// Use placeholders if could not get from external sources
e.setBio(artist, "Biography not available")
e.setSmallImageUrl(artist, placeholderArtistImageSmallUrl)
e.setMediumImageUrl(artist, placeholderArtistImageMediumUrl)
e.setLargeImageUrl(artist, placeholderArtistImageLargeUrl)
artist.ExternalInfoUpdatedAt = time.Now()
err = e.ds.Artist(ctx).Put(artist)
if err != nil {
log.Error(ctx, "Error trying to update artistImageUrl", "id", id, err)
}
if !includeNotPresent {
similar := artist.SimilarArtists
artist.SimilarArtists = nil
for _, s := range similar {
if s.ID == UnavailableArtistID {
continue
}
artist.SimilarArtists = append(artist.SimilarArtists, s)
}
}
log.Trace(ctx, "ArtistInfo collected", "artist", artist)
return artist, nil
}
func (e *externalInfo) getArtist(ctx context.Context, id string) (*model.Artist, error) {
var entity interface{} var entity interface{}
entity, err = GetEntityByID(ctx, e.ds, id) entity, err := GetEntityByID(ctx, e.ds, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
switch v := entity.(type) { switch v := entity.(type) {
case *model.Artist: case *model.Artist:
artist = v return v, nil
case *model.MediaFile: case *model.MediaFile:
artist = &model.Artist{ return e.ds.Artist(ctx).Get(v.ArtistID)
ID: v.ArtistID,
Name: v.Artist,
}
case *model.Album: case *model.Album:
artist = &model.Artist{ return e.ds.Artist(ctx).Get(v.AlbumArtistID)
ID: v.AlbumArtistID,
Name: v.Artist,
}
default:
err = model.ErrNotFound
return
} }
artist.Name = clearName(artist.Name) return nil, model.ErrNotFound
return
} }
// Replace some Unicode chars with their equivalent ASCII // Replace some Unicode chars with their equivalent ASCII
@ -87,7 +130,7 @@ func (e *externalInfo) SimilarSongs(ctx context.Context, id string, count int) (
return nil, err return nil, err
} }
artists, err := e.similarArtists(ctx, artist, count, false) artists, err := e.similarArtists(ctx, clearName(artist.Name), count, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -104,19 +147,19 @@ func (e *externalInfo) SimilarSongs(ctx context.Context, id string, count int) (
}) })
} }
func (e *externalInfo) similarArtists(ctx context.Context, artist *model.Artist, count int, includeNotPresent bool) (model.Artists, error) { func (e *externalInfo) similarArtists(ctx context.Context, artistName string, count int, includeNotPresent bool) (model.Artists, error) {
var result model.Artists var result model.Artists
var notPresent []string var notPresent []string
log.Debug(ctx, "Calling Last.FM ArtistGetSimilar", "artist", artist.Name) log.Debug(ctx, "Calling Last.FM ArtistGetSimilar", "artist", artistName)
similar, err := e.lfm.ArtistGetSimilar(ctx, artist.Name, count) similar, err := e.lfm.ArtistGetSimilar(ctx, artistName, count)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// First select artists that are present. // First select artists that are present.
for _, s := range similar { for _, s := range similar {
sa, err := e.ds.Artist(ctx).FindByName(s.Name) sa, err := e.findArtistByName(ctx, s.Name)
if err != nil { if err != nil {
notPresent = append(notPresent, s.Name) notPresent = append(notPresent, s.Name)
continue continue
@ -127,7 +170,7 @@ func (e *externalInfo) similarArtists(ctx context.Context, artist *model.Artist,
// Then fill up with non-present artists // Then fill up with non-present artists
if includeNotPresent { if includeNotPresent {
for _, s := range notPresent { for _, s := range notPresent {
sa := model.Artist{ID: "-1", Name: s} sa := model.Artist{ID: UnavailableArtistID, Name: s}
result = append(result, sa) result = append(result, sa)
} }
} }
@ -135,12 +178,26 @@ func (e *externalInfo) similarArtists(ctx context.Context, artist *model.Artist,
return result, nil return result, nil
} }
func (e *externalInfo) findArtistByName(ctx context.Context, artistName string) (*model.Artist, error) {
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Like{"name": artistName},
Max: 1,
})
if err != nil {
return nil, err
}
if len(artists) == 0 {
return nil, model.ErrNotFound
}
return &artists[0], nil
}
func (e *externalInfo) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) { func (e *externalInfo) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
if e.lfm == nil { if e.lfm == nil {
log.Warn(ctx, "Last.FM client not configured") log.Warn(ctx, "Last.FM client not configured")
return nil, model.ErrNotAvailable return nil, model.ErrNotAvailable
} }
artist, err := e.ds.Artist(ctx).FindByName(artistName) artist, err := e.findArtistByName(ctx, artistName)
if err != nil { if err != nil {
log.Error(ctx, "Artist not found", "name", artistName, err) log.Error(ctx, "Artist not found", "name", artistName, err)
return nil, nil return nil, nil
@ -188,54 +245,28 @@ func (e *externalInfo) findMatchingTrack(ctx context.Context, mbid string, artis
return &mfs[0], nil return &mfs[0], nil
} }
func (e *externalInfo) ArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.ArtistInfo, error) { func (e *externalInfo) callArtistInfo(ctx context.Context, artist *model.Artist, wg *sync.WaitGroup) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
}
info := model.ArtistInfo{ID: artist.ID, Name: artist.Name}
// TODO Load from local: artist.jpg/png/webp, artist.json (with the remaining info)
var wg sync.WaitGroup
e.callArtistInfo(ctx, artist, &wg, &info)
e.callArtistImages(ctx, artist, &wg, &info)
e.callSimilarArtists(ctx, artist, count, includeNotPresent, &wg, &info)
wg.Wait()
// Use placeholders if could not get from external sources
e.setBio(&info, "Biography not available")
e.setSmallImageUrl(&info, placeholderArtistImageSmallUrl)
e.setMediumImageUrl(&info, placeholderArtistImageMediumUrl)
e.setLargeImageUrl(&info, placeholderArtistImageLargeUrl)
log.Trace(ctx, "ArtistInfo collected", "artist", artist.Name, "info", info)
return &info, nil
}
func (e *externalInfo) callArtistInfo(ctx context.Context, artist *model.Artist, wg *sync.WaitGroup, info *model.ArtistInfo) {
if e.lfm != nil { if e.lfm != nil {
log.Debug(ctx, "Calling Last.FM ArtistGetInfo", "artist", artist.Name) name := clearName(artist.Name)
log.Debug(ctx, "Calling Last.FM ArtistGetInfo", "artist", name)
wg.Add(1) wg.Add(1)
go func() { go func() {
start := time.Now() start := time.Now()
defer wg.Done() defer wg.Done()
lfmArtist, err := e.lfm.ArtistGetInfo(ctx, artist.Name) lfmArtist, err := e.lfm.ArtistGetInfo(ctx, name)
if err != nil { if err != nil {
log.Error(ctx, "Error calling Last.FM", "artist", artist.Name, err) log.Error(ctx, "Error calling Last.FM", "artist", name, err)
} else { } else {
log.Debug(ctx, "Got info from Last.FM", "artist", artist.Name, "info", lfmArtist.Bio.Summary, "elapsed", time.Since(start)) log.Debug(ctx, "Got info from Last.FM", "artist", name, "info", lfmArtist.Bio.Summary, "elapsed", time.Since(start))
} }
e.setBio(info, lfmArtist.Bio.Summary) e.setBio(artist, lfmArtist.Bio.Summary)
e.setLastFMUrl(info, lfmArtist.URL) e.setExternalUrl(artist, lfmArtist.URL)
e.setMbzID(info, lfmArtist.MBID) e.setMbzID(artist, lfmArtist.MBID)
}() }()
} }
} }
func (e *externalInfo) findArtist(ctx context.Context, name string) (*spotify.Artist, error) { func (e *externalInfo) searchArtist(ctx context.Context, name string) (*spotify.Artist, error) {
artists, err := e.spf.SearchArtists(ctx, name, 40) artists, err := e.spf.SearchArtists(ctx, name, 40)
if err != nil || len(artists) == 0 { if err != nil || len(artists) == 0 {
return nil, model.ErrNotFound return nil, model.ErrNotFound
@ -256,89 +287,132 @@ func (e *externalInfo) findArtist(ctx context.Context, name string) (*spotify.Ar
return &artists[0], err return &artists[0], err
} }
func (e *externalInfo) callSimilarArtists(ctx context.Context, artist *model.Artist, count int, includeNotPresent bool, func (e *externalInfo) callSimilarArtists(ctx context.Context, artist *model.Artist, count int, wg *sync.WaitGroup) {
wg *sync.WaitGroup, info *model.ArtistInfo) {
if e.lfm != nil { if e.lfm != nil {
name := clearName(artist.Name)
wg.Add(1) wg.Add(1)
go func() { go func() {
start := time.Now() start := time.Now()
defer wg.Done() defer wg.Done()
similar, err := e.similarArtists(ctx, artist, count, includeNotPresent) similar, err := e.similarArtists(ctx, name, count, true)
if err != nil { if err != nil {
log.Error(ctx, "Error calling Last.FM", "artist", artist.Name, err) log.Error(ctx, "Error calling Last.FM", "artist", name, err)
return return
} }
log.Debug(ctx, "Got similar artists from Last.FM", "artist", artist.Name, "info", "elapsed", time.Since(start)) log.Debug(ctx, "Got similar artists from Last.FM", "artist", name, "info", "elapsed", time.Since(start))
info.SimilarArtists = similar artist.SimilarArtists = similar
}() }()
} }
} }
func (e *externalInfo) callArtistImages(ctx context.Context, artist *model.Artist, wg *sync.WaitGroup, info *model.ArtistInfo) { func (e *externalInfo) callArtistImages(ctx context.Context, artist *model.Artist, wg *sync.WaitGroup) {
if e.spf != nil { if e.spf != nil {
log.Debug(ctx, "Calling Spotify SearchArtist", "artist", artist.Name) name := clearName(artist.Name)
log.Debug(ctx, "Calling Spotify SearchArtist", "artist", name)
wg.Add(1) wg.Add(1)
go func() { go func() {
start := time.Now() start := time.Now()
defer wg.Done() defer wg.Done()
a, err := e.findArtist(ctx, artist.Name) a, err := e.searchArtist(ctx, name)
if err != nil { if err != nil {
log.Error(ctx, "Error calling Spotify", "artist", artist.Name, err) if err == model.ErrNotFound {
log.Warn(ctx, "Artist not found in Spotify", "artist", name)
} else {
log.Error(ctx, "Error calling Spotify", "artist", name, err)
}
return return
} }
spfImages := a.Images spfImages := a.Images
log.Debug(ctx, "Got images from Spotify", "artist", artist.Name, "images", spfImages, "elapsed", time.Since(start)) log.Debug(ctx, "Got images from Spotify", "artist", name, "images", spfImages, "elapsed", time.Since(start))
sort.Slice(spfImages, func(i, j int) bool { return spfImages[i].Width > spfImages[j].Width }) sort.Slice(spfImages, func(i, j int) bool { return spfImages[i].Width > spfImages[j].Width })
if len(spfImages) >= 1 { if len(spfImages) >= 1 {
e.setLargeImageUrl(info, spfImages[0].URL) e.setLargeImageUrl(artist, spfImages[0].URL)
} }
if len(spfImages) >= 2 { if len(spfImages) >= 2 {
e.setMediumImageUrl(info, spfImages[1].URL) e.setMediumImageUrl(artist, spfImages[1].URL)
} }
if len(spfImages) >= 3 { if len(spfImages) >= 3 {
e.setSmallImageUrl(info, spfImages[2].URL) e.setSmallImageUrl(artist, spfImages[2].URL)
} }
}() }()
} }
} }
func (e *externalInfo) setBio(info *model.ArtistInfo, bio string) { func (e *externalInfo) setBio(artist *model.Artist, bio string) {
policy := bluemonday.UGCPolicy() policy := bluemonday.UGCPolicy()
if info.Biography == "" { if artist.Biography == "" {
bio = policy.Sanitize(bio) bio = policy.Sanitize(bio)
bio = strings.ReplaceAll(bio, "\n", " ") bio = strings.ReplaceAll(bio, "\n", " ")
info.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ") artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
} }
} }
func (e *externalInfo) setLastFMUrl(info *model.ArtistInfo, url string) { func (e *externalInfo) setExternalUrl(artist *model.Artist, url string) {
if info.LastFMUrl == "" { if artist.ExternalUrl == "" {
info.LastFMUrl = url artist.ExternalUrl = url
} }
} }
func (e *externalInfo) setMbzID(info *model.ArtistInfo, mbID string) { func (e *externalInfo) setMbzID(artist *model.Artist, mbID string) {
if info.MBID == "" { if artist.MbzArtistID == "" {
info.MBID = mbID artist.MbzArtistID = mbID
} }
} }
func (e *externalInfo) setSmallImageUrl(info *model.ArtistInfo, url string) { func (e *externalInfo) setSmallImageUrl(artist *model.Artist, url string) {
if info.SmallImageUrl == "" { if artist.SmallImageUrl == "" {
info.SmallImageUrl = url artist.SmallImageUrl = url
} }
} }
func (e *externalInfo) setMediumImageUrl(info *model.ArtistInfo, url string) { func (e *externalInfo) setMediumImageUrl(artist *model.Artist, url string) {
if info.MediumImageUrl == "" { if artist.MediumImageUrl == "" {
info.MediumImageUrl = url artist.MediumImageUrl = url
} }
} }
func (e *externalInfo) setLargeImageUrl(info *model.ArtistInfo, url string) { func (e *externalInfo) setLargeImageUrl(artist *model.Artist, url string) {
if info.LargeImageUrl == "" { if artist.LargeImageUrl == "" {
info.LargeImageUrl = url artist.LargeImageUrl = url
} }
} }
func (e *externalInfo) loadSimilar(ctx context.Context, artist *model.Artist, includeNotPresent bool) error {
var ids []string
for _, sa := range artist.SimilarArtists {
if sa.ID == UnavailableArtistID {
continue
}
ids = append(ids, sa.ID)
}
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"id": ids},
})
if err != nil {
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 {
la, ok := artistMap[sa.ID]
if !ok {
if !includeNotPresent {
continue
}
la = sa
la.ID = UnavailableArtistID
}
loaded = append(loaded, la)
}
artist.SimilarArtists = loaded
return nil
}

View File

@ -61,7 +61,7 @@ func NewPool(name string, workerCount int, item interface{}, exec Executor) (*Po
log.Debug("Queue status", "pool", p.name, "items", len(p.queue)) log.Debug("Queue status", "pool", p.name, "items", len(p.queue))
} else { } else {
if running { if running {
log.Info("Finished draining queue", "pool", p.name) log.Info("Queue empty", "pool", p.name)
} }
running = false running = false
} }

View File

@ -59,7 +59,6 @@ func (c *Client) SearchArtists(ctx context.Context, name string, limit int) ([]A
if len(results.Artists.Items) == 0 { if len(results.Artists.Items) == 0 {
return nil, ErrNotFound return nil, ErrNotFound
} }
log.Debug(ctx, "Found artist in Spotify", "artist", results.Artists.Items[0].Name)
return results.Artists.Items, err return results.Artists.Items, err
} }

View File

@ -0,0 +1,35 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upAddArtistImageUrl, downAddArtistImageUrl)
}
func upAddArtistImageUrl(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table artist
add biography varchar(255) default '' not null;
alter table artist
add small_image_url varchar(255) default '' not null;
alter table artist
add medium_image_url varchar(255) default '' not null;
alter table artist
add large_image_url varchar(255) default '' not null;
alter table artist
add similar_artists varchar(255) default '' not null;
alter table artist
add external_url varchar(255) default '' not null;
alter table artist
add external_info_updated_at datetime;
`)
return err
}
func downAddArtistImageUrl(tx *sql.Tx) error {
return nil
}

View File

@ -1,17 +1,36 @@
package model package model
import "time"
type Artist struct { type Artist struct {
Annotations Annotations
ID string `json:"id" orm:"column(id)"` ID string `json:"id" orm:"column(id)"`
Name string `json:"name"` Name string `json:"name"`
AlbumCount int `json:"albumCount"` AlbumCount int `json:"albumCount"`
SongCount int `json:"songCount"` SongCount int `json:"songCount"`
FullText string `json:"fullText"` FullText string `json:"fullText"`
SortArtistName string `json:"sortArtistName"` SortArtistName string `json:"sortArtistName"`
OrderArtistName string `json:"orderArtistName"` OrderArtistName string `json:"orderArtistName"`
Size int64 `json:"size"` Size int64 `json:"size"`
MbzArtistID string `json:"mbzArtistId" orm:"column(mbz_artist_id)"` MbzArtistID string `json:"mbzArtistId" orm:"column(mbz_artist_id)"`
Biography string `json:"biography"`
SmallImageUrl string `json:"smallImageUrl"`
MediumImageUrl string `json:"mediumImageUrl"`
LargeImageUrl string `json:"largeImageUrl"`
ExternalUrl string `json:"externalUrl" orm:"column(external_url)"`
SimilarArtists Artists `json:"-" orm:"-"`
ExternalInfoUpdatedAt time.Time `json:"externalInfoUpdatedAt"`
}
func (a Artist) ArtistImageUrl() string {
if a.MediumImageUrl != "" {
return a.MediumImageUrl
}
if a.LargeImageUrl != "" {
return a.LargeImageUrl
}
return a.SmallImageUrl
} }
type Artists []Artist type Artists []Artist
@ -27,7 +46,7 @@ type ArtistRepository interface {
Exists(id string) (bool, error) Exists(id string) (bool, error)
Put(m *Artist) error Put(m *Artist) error
Get(id string) (*Artist, error) Get(id string) (*Artist, error)
FindByName(name string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error)
GetStarred(options ...QueryOptions) (Artists, error) GetStarred(options ...QueryOptions) (Artists, error)
Search(q string, offset int, size int) (Artists, error) Search(q string, offset int, size int) (Artists, error)
Refresh(ids ...string) error Refresh(ids ...string) error

View File

@ -2,6 +2,8 @@ package persistence
import ( import (
"context" "context"
"fmt"
"net/url"
"sort" "sort"
"strings" "strings"
@ -20,6 +22,11 @@ type artistRepository struct {
indexGroups utils.IndexGroups indexGroups utils.IndexGroups
} }
type dbArtist struct {
model.Artist
SimilarArtists string `json:"similarArtists"`
}
func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepository { func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepository {
r := &artistRepository{} r := &artistRepository{}
r.ctx = ctx r.ctx = ctx
@ -49,42 +56,71 @@ func (r *artistRepository) Exists(id string) (bool, error) {
} }
func (r *artistRepository) Put(a *model.Artist) error { func (r *artistRepository) Put(a *model.Artist) error {
a.FullText = getFullText(a.Name, a.SortArtistName) dba := r.fromModel(a)
_, err := r.put(a.ID, a) a.FullText = getFullText(dba.Name, dba.SortArtistName)
_, err := r.put(dba.ID, dba)
return err return err
} }
func (r *artistRepository) Get(id string) (*model.Artist, error) { func (r *artistRepository) Get(id string) (*model.Artist, error) {
sel := r.selectArtist().Where(Eq{"id": id}) sel := r.selectArtist().Where(Eq{"id": id})
var res model.Artists var dba []dbArtist
if err := r.queryAll(sel, &res); err != nil { if err := r.queryAll(sel, &dba); err != nil {
return nil, err return nil, err
} }
if len(res) == 0 { if len(dba) == 0 {
return nil, model.ErrNotFound
}
return &res[0], nil
}
func (r *artistRepository) FindByName(name string) (*model.Artist, error) {
sel := r.selectArtist().Where(Like{"name": name})
var res model.Artists
if err := r.queryAll(sel, &res); err != nil {
return nil, err
}
if len(res) == 0 {
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
res := r.toModels(dba)
return &res[0], nil return &res[0], nil
} }
func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) { func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) {
sel := r.selectArtist(options...) sel := r.selectArtist(options...)
res := model.Artists{} var dba []dbArtist
err := r.queryAll(sel, &res) err := r.queryAll(sel, &dba)
res := r.toModels(dba)
return res, err return res, err
} }
func (r *artistRepository) toModels(dba []dbArtist) model.Artists {
var res model.Artists
for i := range dba {
a := dba[i]
res = append(res, *r.toModel(&a))
}
return res
}
func (r *artistRepository) toModel(dba *dbArtist) *model.Artist {
a := dba.Artist
a.SimilarArtists = nil
for _, s := range strings.Split(dba.SimilarArtists, ";") {
fields := strings.Split(s, ":")
if len(fields) != 2 {
continue
}
name, _ := url.QueryUnescape(fields[1])
a.SimilarArtists = append(a.SimilarArtists, model.Artist{
ID: fields[0],
Name: name,
})
}
return &a
}
func (r *artistRepository) fromModel(a *model.Artist) *dbArtist {
dba := &dbArtist{Artist: *a}
var sa []string
for _, s := range a.SimilarArtists {
sa = append(sa, fmt.Sprintf("%s:%s", s.ID, url.QueryEscape(s.Name)))
}
dba.SimilarArtists = strings.Join(sa, ";")
return dba
}
func (r *artistRepository) getIndexKey(a *model.Artist) string { func (r *artistRepository) getIndexKey(a *model.Artist) string {
name := strings.ToLower(utils.NoArticle(a.Name)) name := strings.ToLower(utils.NoArticle(a.Name))
for k, v := range r.indexGroups { for k, v := range r.indexGroups {
@ -98,9 +134,7 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
// TODO Cache the index (recalculate when there are changes to the DB) // TODO Cache the index (recalculate when there are changes to the DB)
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) { func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
sq := r.selectArtist().OrderBy("order_artist_name") all, err := r.GetAll(model.QueryOptions{Sort: "order_artist_name"})
var all model.Artists
err := r.queryAll(sq, &all)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -181,8 +215,9 @@ func (r *artistRepository) refresh(ids ...string) error {
func (r *artistRepository) GetStarred(options ...model.QueryOptions) (model.Artists, error) { func (r *artistRepository) GetStarred(options ...model.QueryOptions) (model.Artists, error) {
sq := r.selectArtist(options...).Where("starred = true") sq := r.selectArtist(options...).Where("starred = true")
starred := model.Artists{} var dba []dbArtist
err := r.queryAll(sq, &starred) err := r.queryAll(sq, &dba)
starred := r.toModels(dba)
return starred, err return starred, err
} }
@ -198,9 +233,12 @@ func (r *artistRepository) purgeEmpty() error {
} }
func (r *artistRepository) Search(q string, offset int, size int) (model.Artists, error) { func (r *artistRepository) Search(q string, offset int, size int) (model.Artists, error) {
results := model.Artists{} var dba []dbArtist
err := r.doSearch(q, offset, size, &results, "name") err := r.doSearch(q, offset, size, &dba, "name")
return results, err if err != nil {
return nil, err
}
return r.toModels(dba), nil
} }
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {

View File

@ -9,6 +9,7 @@ import (
"github.com/deluan/navidrome/model/request" "github.com/deluan/navidrome/model/request"
. "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
) )
var _ = Describe("ArtistRepository", func() { var _ = Describe("ArtistRepository", func() {
@ -69,4 +70,26 @@ var _ = Describe("ArtistRepository", func() {
})) }))
}) })
}) })
Describe("dbArtist mapping", func() {
var a *model.Artist
BeforeEach(func() {
a = &model.Artist{ID: "1", Name: "Van Halen", SimilarArtists: []model.Artist{
{ID: "2", Name: "AC/DC"}, {ID: "-1", Name: "Test;With:Sep,Chars"},
}}
})
It("maps fields", func() {
dba := repo.(*artistRepository).fromModel(a)
actual := repo.(*artistRepository).toModel(dba)
Expect(*actual).To(MatchFields(IgnoreExtras, Fields{
"ID": Equal(a.ID),
"Name": Equal(a.Name),
}))
Expect(actual.SimilarArtists).To(HaveLen(2))
Expect(actual.SimilarArtists[0].ID).To(Equal("2"))
Expect(actual.SimilarArtists[0].Name).To(Equal("AC/DC"))
Expect(actual.SimilarArtists[1].ID).To(Equal("-1"))
Expect(actual.SimilarArtists[1].Name).To(Equal("Test;With:Sep,Chars"))
})
})
}) })

View File

@ -69,12 +69,7 @@ func (c *BrowsingController) getArtistIndex(ctx context.Context, mediaFolderId i
res.Index = make([]responses.Index, len(indexes)) res.Index = make([]responses.Index, len(indexes))
for i, idx := range indexes { for i, idx := range indexes {
res.Index[i].Name = idx.ID res.Index[i].Name = idx.ID
res.Index[i].Artists = make([]responses.Artist, len(idx.Artists)) res.Index[i].Artists = toArtists(ctx, idx.Artists)
for j, a := range idx.Artists {
res.Index[i].Artists[j].Id = a.ID
res.Index[i].Artists[j].Name = a.Name
res.Index[i].Artists[j].AlbumCount = a.AlbumCount
}
} }
return res, nil return res, nil
} }
@ -241,28 +236,21 @@ func (c *BrowsingController) GetArtistInfo(w http.ResponseWriter, r *http.Reques
count := utils.ParamInt(r, "count", 20) count := utils.ParamInt(r, "count", 20)
includeNotPresent := utils.ParamBool(r, "includeNotPresent", false) includeNotPresent := utils.ParamBool(r, "includeNotPresent", false)
info, err := c.ei.ArtistInfo(ctx, id, count, includeNotPresent) artist, err := c.ei.UpdateArtistInfo(ctx, id, count, includeNotPresent)
if err != nil { if err != nil {
return nil, err return nil, err
} }
response := newResponse() response := newResponse()
response.ArtistInfo = &responses.ArtistInfo{} response.ArtistInfo = &responses.ArtistInfo{}
response.ArtistInfo.Biography = info.Biography response.ArtistInfo.Biography = artist.Biography
response.ArtistInfo.SmallImageUrl = info.SmallImageUrl response.ArtistInfo.SmallImageUrl = artist.SmallImageUrl
response.ArtistInfo.MediumImageUrl = info.MediumImageUrl response.ArtistInfo.MediumImageUrl = artist.MediumImageUrl
response.ArtistInfo.LargeImageUrl = info.LargeImageUrl response.ArtistInfo.LargeImageUrl = artist.LargeImageUrl
response.ArtistInfo.LastFmUrl = info.LastFMUrl response.ArtistInfo.LastFmUrl = artist.ExternalUrl
response.ArtistInfo.MusicBrainzID = info.MBID response.ArtistInfo.MusicBrainzID = artist.MbzArtistID
for _, s := range info.SimilarArtists { for _, s := range artist.SimilarArtists {
similar := responses.Artist{} similar := toArtist(ctx, s)
similar.Id = s.ID
similar.Name = s.Name
similar.AlbumCount = s.AlbumCount
if s.Starred {
similar.Starred = &s.StarredAt
}
similar.UserRating = s.Rating
response.ArtistInfo.SimilarArtist = append(response.ArtistInfo.SimilarArtist, similar) response.ArtistInfo.SimilarArtist = append(response.ArtistInfo.SimilarArtist, similar)
} }
return response, nil return response, nil
@ -283,6 +271,7 @@ func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Reque
similar.Name = s.Name similar.Name = s.Name
similar.AlbumCount = s.AlbumCount similar.AlbumCount = s.AlbumCount
similar.Starred = s.Starred similar.Starred = s.Starred
similar.ArtistImageUrl = s.ArtistImageUrl
response.ArtistInfo2.SimilarArtist = append(response.ArtistInfo2.SimilarArtist, similar) response.ArtistInfo2.SimilarArtist = append(response.ArtistInfo2.SimilarArtist, similar)
} }
return response, nil return response, nil
@ -362,16 +351,10 @@ func (c *BrowsingController) buildArtistDirectory(ctx context.Context, artist *m
} }
func (c *BrowsingController) buildArtist(ctx context.Context, artist *model.Artist, albums model.Albums) *responses.ArtistWithAlbumsID3 { func (c *BrowsingController) buildArtist(ctx context.Context, artist *model.Artist, albums model.Albums) *responses.ArtistWithAlbumsID3 {
dir := &responses.ArtistWithAlbumsID3{} a := &responses.ArtistWithAlbumsID3{}
dir.Id = artist.ID a.ArtistID3 = toArtistID3(ctx, *artist)
dir.Name = artist.Name a.Album = childrenFromAlbums(ctx, albums)
dir.AlbumCount = artist.AlbumCount return a
if artist.Starred {
dir.Starred = &artist.StarredAt
}
dir.Album = childrenFromAlbums(ctx, albums)
return dir
} }
func (c *BrowsingController) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) { func (c *BrowsingController) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) {

View File

@ -74,19 +74,38 @@ func getUser(ctx context.Context) string {
func toArtists(ctx context.Context, artists model.Artists) []responses.Artist { func toArtists(ctx context.Context, artists model.Artists) []responses.Artist {
as := make([]responses.Artist, len(artists)) as := make([]responses.Artist, len(artists))
for i, artist := range artists { for i, artist := range artists {
as[i] = responses.Artist{ as[i] = toArtist(ctx, artist)
Id: artist.ID,
Name: artist.Name,
AlbumCount: artist.AlbumCount,
UserRating: artist.Rating,
}
if artist.Starred {
as[i].Starred = &artist.StarredAt
}
} }
return as return as
} }
func toArtist(ctx context.Context, a model.Artist) responses.Artist {
artist := responses.Artist{
Id: a.ID,
Name: a.Name,
AlbumCount: a.AlbumCount,
UserRating: a.Rating,
ArtistImageUrl: a.ArtistImageUrl(),
}
if a.Starred {
artist.Starred = &a.StarredAt
}
return artist
}
func toArtistID3(ctx context.Context, a model.Artist) responses.ArtistID3 {
artist := responses.ArtistID3{
Id: a.ID,
Name: a.Name,
AlbumCount: a.AlbumCount,
ArtistImageUrl: a.ArtistImageUrl(),
}
if a.Starred {
artist.Starred = &a.StarredAt
}
return artist
}
func toGenres(genres model.Genres) *responses.Genres { func toGenres(genres model.Genres) *responses.Genres {
response := make([]responses.Genre, len(genres)) response := make([]responses.Genre, len(genres))
for i, g := range genres { for i, g := range genres {

View File

@ -1 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","indexes":{"index":[{"name":"A","artist":[{"id":"111","name":"aaa","albumCount":2,"starred":"2016-03-02T20:30:00Z","userRating":3}]}],"lastModified":1,"ignoredArticles":"A"}} {"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","indexes":{"index":[{"name":"A","artist":[{"id":"111","name":"aaa","albumCount":2,"starred":"2016-03-02T20:30:00Z","userRating":3,"artistImageUrl":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"}]}],"lastModified":1,"ignoredArticles":"A"}}

View File

@ -1 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><indexes lastModified="1" ignoredArticles="A"><index name="A"><artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3"></artist></index></indexes></subsonic-response> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><indexes lastModified="1" ignoredArticles="A"><index name="A"><artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"></artist></index></indexes></subsonic-response>

View File

@ -71,11 +71,12 @@ type MusicFolders struct {
} }
type Artist struct { type Artist struct {
Id string `xml:"id,attr" json:"id"` Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"` Name string `xml:"name,attr" json:"name"`
AlbumCount int `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"` AlbumCount int `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
UserRating int `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` UserRating int `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
/* /*
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 --> <xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
*/ */
@ -158,11 +159,12 @@ type Directory struct {
} }
type ArtistID3 struct { type ArtistID3 struct {
Id string `xml:"id,attr" json:"id"` Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"` Name string `xml:"name,attr" json:"name"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
AlbumCount int `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"` AlbumCount int `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
} }
type AlbumID3 struct { type AlbumID3 struct {

View File

@ -92,7 +92,14 @@ var _ = Describe("Responses", func() {
BeforeEach(func() { BeforeEach(func() {
artists := make([]Artist, 1) artists := make([]Artist, 1)
t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
artists[0] = Artist{Id: "111", Name: "aaa", Starred: &t, UserRating: 3, AlbumCount: 2} artists[0] = Artist{
Id: "111",
Name: "aaa",
Starred: &t,
UserRating: 3,
AlbumCount: 2,
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
}
index := make([]Index, 1) index := make([]Index, 1)
index[0] = Index{Name: "A", Artists: artists} index[0] = Index{Name: "A", Artists: artists}
response.Indexes.Index = index response.Indexes.Index = index

View File

@ -96,6 +96,7 @@ func (c *SearchingController) Search2(w http.ResponseWriter, r *http.Request) (*
} }
func (c *SearchingController) Search3(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { func (c *SearchingController) Search3(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
sp, err := c.getParams(r) sp, err := c.getParams(r)
if err != nil { if err != nil {
return nil, err return nil, err
@ -106,17 +107,10 @@ func (c *SearchingController) Search3(w http.ResponseWriter, r *http.Request) (*
searchResult3 := &responses.SearchResult3{} searchResult3 := &responses.SearchResult3{}
searchResult3.Artist = make([]responses.ArtistID3, len(as)) searchResult3.Artist = make([]responses.ArtistID3, len(as))
for i, artist := range as { for i, artist := range as {
searchResult3.Artist[i] = responses.ArtistID3{ searchResult3.Artist[i] = toArtistID3(ctx, artist)
Id: artist.ID,
Name: artist.Name,
AlbumCount: artist.AlbumCount,
}
if artist.Starred {
searchResult3.Artist[i].Starred = &artist.StarredAt
}
} }
searchResult3.Album = childrenFromAlbums(r.Context(), als) searchResult3.Album = childrenFromAlbums(ctx, als)
searchResult3.Song = childrenFromMediaFiles(r.Context(), mfs) searchResult3.Song = childrenFromMediaFiles(ctx, mfs)
response.SearchResult3 = searchResult3 response.SearchResult3 = searchResult3
return response, nil return response, nil
} }