Merge branch 'master' into dlna-spike

This commit is contained in:
Rob Emery 2025-03-16 17:38:39 +00:00
commit f6d4295d42
19 changed files with 237 additions and 77 deletions

View File

@ -129,7 +129,8 @@ type scannerOptions struct {
WatcherWait time.Duration
ScanOnStartup bool
Extractor string
GroupAlbumReleases bool // Deprecated: BFR Update docs
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
}
type subsonicOptions struct {
@ -140,6 +141,7 @@ type subsonicOptions struct {
}
type TagConf struct {
Ignore bool `yaml:"ignore"`
Aliases []string `yaml:"aliases"`
Type string `yaml:"type"`
MaxLength int `yaml:"maxLength"`
@ -312,6 +314,7 @@ func Load(noConfigDump bool) {
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
Server.Scanner.Extractor = consts.DefaultScannerExtractor
}
logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases")
// Call init hooks
@ -494,9 +497,10 @@ func init() {
viper.SetDefault("scanner.enabled", true)
viper.SetDefault("scanner.schedule", "0")
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
viper.SetDefault("scanner.scanonstartup", true)
viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false)

View File

@ -172,14 +172,20 @@ join library on media_file.library_id = library.id`, string(os.PathSeparator)))
// Finally, walk the in-mem filesystem and insert all folders into the DB.
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
// Don't abort the walk, just log the error
log.Error("error walking folder to DB", "path", path, err)
return nil
}
if d.IsDir() {
f := model.NewFolder(lib, path)
_, err = stmt.ExecContext(ctx, f.ID, lib.ID, f.Path, f.Name, f.ParentID)
if err != nil {
log.Error("error writing folder to DB", "path", path, err)
}
// Skip entries that are not directories
if !d.IsDir() {
return nil
}
// Create a folder in the DB
f := model.NewFolder(lib, path)
_, err = stmt.ExecContext(ctx, f.ID, lib.ID, f.Path, f.Name, f.ParentID)
if err != nil {
log.Error("error writing folder to DB", "path", path, err)
}
return err
})

View File

@ -1,6 +1,7 @@
package model
import (
"cmp"
"maps"
"regexp"
"slices"
@ -175,21 +176,42 @@ func loadTagMappings() {
log.Error("No tag mappings found in mappings.yaml, check the format")
}
// Use Scanner.GenreSeparators if specified and Tags.genre is not defined
if conf.Server.Scanner.GenreSeparators != "" && len(conf.Server.Tags["genre"].Aliases) == 0 {
genreConf := _mappings.Main[TagName("genre")]
genreConf.Split = strings.Split(conf.Server.Scanner.GenreSeparators, "")
genreConf.SplitRx = compileSplitRegex("genre", genreConf.Split)
_mappings.Main[TagName("genre")] = genreConf
log.Debug("Loading deprecated list of genre separators", "separators", genreConf.Split)
}
// Overwrite the default mappings with the ones from the config
for tag, cfg := range conf.Server.Tags {
if len(cfg.Aliases) == 0 {
if cfg.Ignore {
delete(_mappings.Main, TagName(tag))
delete(_mappings.Additional, TagName(tag))
continue
}
c := TagConf{
Aliases: cfg.Aliases,
Type: TagType(cfg.Type),
MaxLength: cfg.MaxLength,
Split: cfg.Split,
Album: cfg.Album,
SplitRx: compileSplitRegex(TagName(tag), cfg.Split),
oldValue, ok := _mappings.Main[TagName(tag)]
if !ok {
oldValue = _mappings.Additional[TagName(tag)]
}
aliases := cfg.Aliases
if len(aliases) == 0 {
aliases = oldValue.Aliases
}
split := cfg.Split
if len(split) == 0 {
split = oldValue.Split
}
c := TagConf{
Aliases: aliases,
Split: split,
Type: cmp.Or(TagType(cfg.Type), oldValue.Type),
MaxLength: cmp.Or(cfg.MaxLength, oldValue.MaxLength),
Album: cmp.Or(cfg.Album, oldValue.Album),
}
c.SplitRx = compileSplitRegex(TagName(tag), c.Split)
if _, ok := _mappings.Main[TagName(tag)]; ok {
_mappings.Main[TagName(tag)] = c
} else {

View File

@ -6,6 +6,7 @@ import (
"fmt"
"maps"
"slices"
"strings"
"sync"
"time"
@ -119,11 +120,17 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
"has_rating": hasRatingFilter,
"missing": booleanFilter,
"genre_id": tagIDFilter,
"role_total_id": allRolesFilter,
}
// Add all album tags as filters
for tag := range model.AlbumLevelTags() {
filters[string(tag)] = tagIDFilter
}
for role := range model.AllRoles {
filters["role_"+role+"_id"] = artistRoleFilter
}
return filters
})
@ -153,14 +160,25 @@ func yearFilter(_ string, value interface{}) Sqlizer {
}
}
// BFR: Support other roles
func artistFilter(_ string, value interface{}) Sqlizer {
return Or{
Exists("json_tree(Participants, '$.albumartist')", Eq{"value": value}),
Exists("json_tree(Participants, '$.artist')", Eq{"value": value}),
Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}),
Exists("json_tree(participants, '$.artist')", Eq{"value": value}),
}
// For any role:
//return Like{"Participants": fmt.Sprintf(`%%"%s"%%`, value)}
}
func artistRoleFilter(name string, value interface{}) Sqlizer {
roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id")
// Check if the role name is valid. If not, return an invalid filter
if _, ok := model.AllRoles[roleName]; !ok {
return Gt{"": nil}
}
return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value})
}
func allRolesFilter(_ string, value interface{}) Sqlizer {
return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)}
}
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {

View File

@ -2,6 +2,7 @@ package persistence
import (
"context"
"fmt"
"time"
"github.com/navidrome/navidrome/conf"
@ -236,6 +237,52 @@ var _ = Describe("AlbumRepository", func() {
}
})
})
Describe("artistRoleFilter", func() {
DescribeTable("creates correct SQL expressions for artist roles",
func(filterName, artistID, expectedSQL string) {
sqlizer := artistRoleFilter(filterName, artistID)
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSQL))
Expect(args).To(Equal([]interface{}{artistID}))
},
Entry("artist role", "role_artist_id", "123",
"exists (select 1 from json_tree(participants, '$.artist') where value = ?)"),
Entry("albumartist role", "role_albumartist_id", "456",
"exists (select 1 from json_tree(participants, '$.albumartist') where value = ?)"),
Entry("composer role", "role_composer_id", "789",
"exists (select 1 from json_tree(participants, '$.composer') where value = ?)"),
)
It("works with the actual filter map", func() {
filters := albumFilters()
for roleName := range model.AllRoles {
filterName := "role_" + roleName + "_id"
filterFunc, exists := filters[filterName]
Expect(exists).To(BeTrue(), fmt.Sprintf("Filter %s should exist", filterName))
sqlizer := filterFunc(filterName, "test-id")
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName)))
Expect(args).To(Equal([]interface{}{"test-id"}))
}
})
It("rejects invalid roles", func() {
sqlizer := artistRoleFilter("role_invalid_id", "123")
_, _, err := sqlizer.ToSql()
Expect(err).To(HaveOccurred())
})
It("rejects invalid filter names", func() {
sqlizer := artistRoleFilter("invalid_name", "123")
_, _, err := sqlizer.ToSql()
Expect(err).To(HaveOccurred())
})
})
})
func _p(id, name string, sortName ...string) model.Participant {

View File

@ -4,7 +4,6 @@ import (
"context"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
@ -53,17 +52,6 @@ var _ = Describe("MediaRepository", func() {
Expect(err).To(MatchError(model.ErrNotFound))
})
XIt("filters by genre", func() {
Expect(mr.GetAll(model.QueryOptions{
Sort: "genre.name asc, title asc",
Filters: squirrel.Eq{"genre.name": "Rock"},
})).To(Equal(model.MediaFiles{
songDayInALife,
songAntenna,
songComeTogether,
}))
})
Context("Annotations", func() {
It("increments play count when the tracks does not have annotations", func() {
id := "incplay.firsttime"

View File

@ -1,6 +1,14 @@
#file: noinspection SpellCheckingInspection
# Tag mapping adapted from https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
#
# NOTE FOR USERS:
#
# This file can be used as a reference to understand how Navidrome maps the tags in your music files to its fields.
# If you want to customize these mappings, please refer to https://www.navidrome.org/docs/usage/customtags/
#
#
# NOTE FOR DEVELOPERS:
#
# This file contains the mapping between the tags in your music files and the fields in Navidrome.
# You can add new tags, change the aliases, or add new split characters to the existing tags.
# The artists and roles keys are used to define how to split the tag values into multiple values.
@ -96,7 +104,7 @@ main:
aliases: [ disctotal, totaldiscs ]
album: true
discsubtitle:
aliases: [ tsst, discsubtitle, ----:com.apple.itunes:discsubtitle, wm/setsubtitle ]
aliases: [ tsst, discsubtitle, ----:com.apple.itunes:discsubtitle, setsubtitle, wm/setsubtitle ]
bpm:
aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ]
lyrics:

View File

@ -334,7 +334,8 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
// Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later
for i := range entry.artists {
err = artistRepo.Put(&entry.artists[i], "name", "mbz_artist_id", "sort_artist_name", "order_artist_name")
err = artistRepo.Put(&entry.artists[i], "name",
"mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text")
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err)
return err

View File

@ -145,7 +145,10 @@ func loadIgnoredPatterns(ctx context.Context, fsys fs.FS, currentFolder string,
}
// If the .ndignore file is empty, mimic the current behavior and ignore everything
if len(newPatterns) == 0 {
log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", currentFolder)
newPatterns = []string{"**/*"}
} else {
log.Trace(ctx, "Scanner: .ndignore file found ", "path", ignoreFilePath, "patterns", newPatterns)
}
}
// Combine the patterns from the .ndignore file with the ones passed as argument
@ -180,7 +183,7 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, ignorePatterns [
children = make([]string, 0, len(entries))
for _, entry := range entries {
entryPath := path.Join(dirPath, entry.Name())
if len(ignorePatterns) > 0 && isScanIgnored(ignoreMatcher, entryPath) {
if len(ignorePatterns) > 0 && isScanIgnored(ctx, ignoreMatcher, entryPath) {
log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath)
continue
}
@ -309,6 +312,10 @@ func isEntryIgnored(name string) bool {
return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..")
}
func isScanIgnored(matcher *ignore.GitIgnore, entryPath string) bool {
return matcher.MatchesPath(entryPath)
func isScanIgnored(ctx context.Context, matcher *ignore.GitIgnore, entryPath string) bool {
matches := matcher.MatchesPath(entryPath)
if matches {
log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore: ", "path", entryPath)
}
return matches
}

View File

@ -101,22 +101,27 @@ func watchLib(ctx context.Context, lib model.Library, watchChan chan struct{}) {
log.Error(ctx, "Watcher: Error watching library", "library", lib.ID, "path", lib.Path, err)
return
}
log.Info(ctx, "Watcher started", "library", lib.ID, "path", lib.Path)
absLibPath, err := filepath.Abs(lib.Path)
if err != nil {
log.Error(ctx, "Watcher: Error converting lib.Path to absolute", "library", lib.ID, "path", lib.Path, err)
return
}
log.Info(ctx, "Watcher started", "library", lib.ID, "libPath", lib.Path, "absoluteLibPath", absLibPath)
for {
select {
case <-ctx.Done():
return
case path := <-c:
path, err = filepath.Rel(lib.Path, path)
path, err = filepath.Rel(absLibPath, path)
if err != nil {
log.Error(ctx, "Watcher: Error getting relative path", "library", lib.ID, "path", path, err)
log.Error(ctx, "Watcher: Error getting relative path", "library", lib.ID, "libPath", absLibPath, "path", path, err)
continue
}
if isIgnoredPath(ctx, fsys, path) {
log.Trace(ctx, "Watcher: Ignoring change", "library", lib.ID, "path", path)
continue
}
log.Trace(ctx, "Watcher: Detected change", "library", lib.ID, "path", path)
log.Trace(ctx, "Watcher: Detected change", "library", lib.ID, "path", path, "libPath", absLibPath)
watchChan <- struct{}{}
}
}

View File

@ -270,30 +270,43 @@ func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) {
return response, nil
}
func (api *Router) GetArtistInfo(r *http.Request) (*responses.Subsonic, error) {
func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *model.Artists, error) {
ctx := r.Context()
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
return nil, nil, err
}
count := p.IntOr("count", 20)
includeNotPresent := p.BoolOr("includeNotPresent", false)
artist, err := api.externalMetadata.UpdateArtistInfo(ctx, id, count, includeNotPresent)
if err != nil {
return nil, nil, err
}
base := responses.ArtistInfoBase{}
base.Biography = artist.Biography
base.SmallImageUrl = public.ImageURL(r, artist.CoverArtID(), 300)
base.MediumImageUrl = public.ImageURL(r, artist.CoverArtID(), 600)
base.LargeImageUrl = public.ImageURL(r, artist.CoverArtID(), 1200)
base.LastFmUrl = artist.ExternalUrl
base.MusicBrainzID = artist.MbzArtistID
return &base, &artist.SimilarArtists, nil
}
func (api *Router) GetArtistInfo(r *http.Request) (*responses.Subsonic, error) {
base, similarArtists, err := api.getArtistInfo(r)
if err != nil {
return nil, err
}
response := newResponse()
response.ArtistInfo = &responses.ArtistInfo{}
response.ArtistInfo.Biography = artist.Biography
response.ArtistInfo.SmallImageUrl = public.ImageURL(r, artist.CoverArtID(), 300)
response.ArtistInfo.MediumImageUrl = public.ImageURL(r, artist.CoverArtID(), 600)
response.ArtistInfo.LargeImageUrl = public.ImageURL(r, artist.CoverArtID(), 1200)
response.ArtistInfo.LastFmUrl = artist.ExternalUrl
response.ArtistInfo.MusicBrainzID = artist.MbzArtistID
for _, s := range artist.SimilarArtists {
response.ArtistInfo.ArtistInfoBase = *base
for _, s := range *similarArtists {
similar := toArtist(r, s)
if s.ID == "" {
similar.Id = "-1"
@ -304,23 +317,20 @@ func (api *Router) GetArtistInfo(r *http.Request) (*responses.Subsonic, error) {
}
func (api *Router) GetArtistInfo2(r *http.Request) (*responses.Subsonic, error) {
info, err := api.GetArtistInfo(r)
base, similarArtists, err := api.getArtistInfo(r)
if err != nil {
return nil, err
}
response := newResponse()
response.ArtistInfo2 = &responses.ArtistInfo2{}
response.ArtistInfo2.ArtistInfoBase = info.ArtistInfo.ArtistInfoBase
for _, s := range info.ArtistInfo.SimilarArtist {
similar := responses.ArtistID3{}
similar.Id = s.Id
similar.Name = s.Name
similar.AlbumCount = s.AlbumCount
similar.Starred = s.Starred
similar.UserRating = s.UserRating
similar.CoverArt = s.CoverArt
similar.ArtistImageUrl = s.ArtistImageUrl
response.ArtistInfo2.ArtistInfoBase = *base
for _, s := range *similarArtists {
similar := toArtistID3(r, s)
if s.ID == "" {
similar.Id = "-1"
}
response.ArtistInfo2.SimilarArtist = append(response.ArtistInfo2.SimilarArtist, similar)
}
return response, nil

View File

@ -95,7 +95,7 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
}
ff := And{}
if genre != "" {
ff = append(ff, Eq{"genre.name": genre})
ff = append(ff, filterByGenre(genre))
}
if fromYear != 0 {
ff = append(ff, GtOrEq{"year": fromYear})
@ -118,11 +118,15 @@ func SongWithLyrics(artist, title string) Options {
func ByGenre(genre string) Options {
return addDefaultFilters(Options{
Sort: "name asc",
Filters: persistence.Exists("json_tree(tags)", And{
Like{"value": genre},
NotEq{"atom": nil},
}),
Sort: "name asc",
Filters: filterByGenre(genre),
})
}
func filterByGenre(genre string) Sqlizer {
return persistence.Exists("json_tree(tags)", And{
Like{"value": genre},
NotEq{"atom": nil},
})
}

View File

@ -77,11 +77,26 @@ func sortName(sortName, orderName string) string {
return orderName
}
func getArtistAlbumCount(a model.Artist) int32 {
albumStats := a.Stats[model.RoleAlbumArtist]
// If ArtistParticipations are set, then `getArtist` will return albums
// where the artist is an album artist OR artist. While it may be an underestimate,
// guess the count by taking a max of the album artist and artist count. This is
// guaranteed to be <= the actual count.
// Otherwise, return just the roles as album artist (precise)
if conf.Server.Subsonic.ArtistParticipations {
artistStats := a.Stats[model.RoleArtist]
return int32(max(artistStats.AlbumCount, albumStats.AlbumCount))
} else {
return int32(albumStats.AlbumCount)
}
}
func toArtist(r *http.Request, a model.Artist) responses.Artist {
artist := responses.Artist{
Id: a.ID,
Name: a.Name,
AlbumCount: int32(a.AlbumCount),
UserRating: int32(a.Rating),
CoverArt: a.CoverArtID().String(),
ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600),
@ -96,7 +111,7 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 {
artist := responses.ArtistID3{
Id: a.ID,
Name: a.Name,
AlbumCount: int32(a.AlbumCount),
AlbumCount: getArtistAlbumCount(a),
CoverArt: a.CoverArtID().String(),
ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600),
UserRating: int32(a.Rating),

View File

@ -10,6 +10,10 @@ import (
)
var _ = Describe("helpers", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
Describe("fakePath", func() {
var mf model.MediaFile
BeforeEach(func() {
@ -134,4 +138,29 @@ var _ = Describe("helpers", func() {
Entry("returns \"explicit\" when the db value is \"e\"", "e", "explicit"),
Entry("returns an empty string when the db value is \"\"", "", ""),
Entry("returns an empty string when there are unexpected values on the db", "abc", ""))
Describe("getArtistAlbumCount", func() {
artist := model.Artist{
Stats: map[model.Role]model.ArtistStats{
model.RoleAlbumArtist: {
AlbumCount: 3,
},
model.RoleArtist: {
AlbumCount: 4,
},
},
}
It("Handles album count without artist participations", func() {
conf.Server.Subsonic.ArtistParticipations = false
result := getArtistAlbumCount(artist)
Expect(result).To(Equal(int32(3)))
})
It("Handles album count without with participations", func() {
conf.Server.Subsonic.ArtistParticipations = true
result := getArtistAlbumCount(artist)
Expect(result).To(Equal(int32(4)))
})
})
})

View File

@ -12,7 +12,6 @@
{
"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"

View File

@ -1,7 +1,7 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<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>
<artist id="111" name="aaa" 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

@ -92,7 +92,6 @@ type MusicFolders struct {
type Artist struct {
Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
AlbumCount int32 `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
@ -233,7 +232,7 @@ type ArtistID3 struct {
Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
AlbumCount int32 `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"`
AlbumCount int32 `xml:"albumCount,attr" json:"albumCount"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`

View File

@ -103,7 +103,6 @@ var _ = Describe("Responses", func() {
Name: "aaa",
Starred: &t,
UserRating: 3,
AlbumCount: 2,
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
}
index := make([]Index, 1)

View File

@ -94,7 +94,6 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) {
a := responses.Artist{
Id: artist.ID,
Name: artist.Name,
AlbumCount: int32(artist.AlbumCount),
UserRating: int32(artist.Rating),
CoverArt: artist.CoverArtID().String(),
ArtistImageUrl: public.ImageURL(r, artist.CoverArtID(), 600),