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 WatcherWait time.Duration
ScanOnStartup bool ScanOnStartup bool
Extractor string 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 { type subsonicOptions struct {
@ -140,6 +141,7 @@ type subsonicOptions struct {
} }
type TagConf struct { type TagConf struct {
Ignore bool `yaml:"ignore"`
Aliases []string `yaml:"aliases"` Aliases []string `yaml:"aliases"`
Type string `yaml:"type"` Type string `yaml:"type"`
MaxLength int `yaml:"maxLength"` 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)) log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
Server.Scanner.Extractor = consts.DefaultScannerExtractor Server.Scanner.Extractor = consts.DefaultScannerExtractor
} }
logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases") logDeprecatedOptions("Scanner.GroupAlbumReleases")
// Call init hooks // Call init hooks
@ -494,9 +497,10 @@ func init() {
viper.SetDefault("scanner.enabled", true) viper.SetDefault("scanner.enabled", true)
viper.SetDefault("scanner.schedule", "0") viper.SetDefault("scanner.schedule", "0")
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor) viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait) viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
viper.SetDefault("scanner.scanonstartup", true) viper.SetDefault("scanner.scanonstartup", true)
viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.artistparticipations", false)

View File

@ -172,15 +172,21 @@ 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. // 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 { err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil { 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() { // Skip entries that are not directories
if !d.IsDir() {
return nil
}
// Create a folder in the DB
f := model.NewFolder(lib, path) f := model.NewFolder(lib, path)
_, err = stmt.ExecContext(ctx, f.ID, lib.ID, f.Path, f.Name, f.ParentID) _, err = stmt.ExecContext(ctx, f.ID, lib.ID, f.Path, f.Name, f.ParentID)
if err != nil { if err != nil {
log.Error("error writing folder to DB", "path", path, err) log.Error("error writing folder to DB", "path", path, err)
} }
}
return err return err
}) })
if err != nil { if err != nil {

View File

@ -1,6 +1,7 @@
package model package model
import ( import (
"cmp"
"maps" "maps"
"regexp" "regexp"
"slices" "slices"
@ -175,21 +176,42 @@ func loadTagMappings() {
log.Error("No tag mappings found in mappings.yaml, check the format") 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 // Overwrite the default mappings with the ones from the config
for tag, cfg := range conf.Server.Tags { for tag, cfg := range conf.Server.Tags {
if len(cfg.Aliases) == 0 { if cfg.Ignore {
delete(_mappings.Main, TagName(tag)) delete(_mappings.Main, TagName(tag))
delete(_mappings.Additional, TagName(tag)) delete(_mappings.Additional, TagName(tag))
continue continue
} }
c := TagConf{ oldValue, ok := _mappings.Main[TagName(tag)]
Aliases: cfg.Aliases, if !ok {
Type: TagType(cfg.Type), oldValue = _mappings.Additional[TagName(tag)]
MaxLength: cfg.MaxLength,
Split: cfg.Split,
Album: cfg.Album,
SplitRx: compileSplitRegex(TagName(tag), cfg.Split),
} }
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 { if _, ok := _mappings.Main[TagName(tag)]; ok {
_mappings.Main[TagName(tag)] = c _mappings.Main[TagName(tag)] = c
} else { } else {

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"maps" "maps"
"slices" "slices"
"strings"
"sync" "sync"
"time" "time"
@ -119,11 +120,17 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
"has_rating": hasRatingFilter, "has_rating": hasRatingFilter,
"missing": booleanFilter, "missing": booleanFilter,
"genre_id": tagIDFilter, "genre_id": tagIDFilter,
"role_total_id": allRolesFilter,
} }
// Add all album tags as filters // Add all album tags as filters
for tag := range model.AlbumLevelTags() { for tag := range model.AlbumLevelTags() {
filters[string(tag)] = tagIDFilter filters[string(tag)] = tagIDFilter
} }
for role := range model.AllRoles {
filters["role_"+role+"_id"] = artistRoleFilter
}
return filters return filters
}) })
@ -153,14 +160,25 @@ func yearFilter(_ string, value interface{}) Sqlizer {
} }
} }
// BFR: Support other roles
func artistFilter(_ string, value interface{}) Sqlizer { func artistFilter(_ string, value interface{}) Sqlizer {
return Or{ return Or{
Exists("json_tree(Participants, '$.albumartist')", Eq{"value": value}), Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}),
Exists("json_tree(Participants, '$.artist')", 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) { func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {

View File

@ -2,6 +2,7 @@ package persistence
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/navidrome/navidrome/conf" "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 { func _p(id, name string, sortName ...string) model.Participant {

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"time" "time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/id"
@ -53,17 +52,6 @@ var _ = Describe("MediaRepository", func() {
Expect(err).To(MatchError(model.ErrNotFound)) 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() { Context("Annotations", func() {
It("increments play count when the tracks does not have annotations", func() { It("increments play count when the tracks does not have annotations", func() {
id := "incplay.firsttime" id := "incplay.firsttime"

View File

@ -1,6 +1,14 @@
#file: noinspection SpellCheckingInspection #file: noinspection SpellCheckingInspection
# Tag mapping adapted from https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html # 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. # 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. # 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. # 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 ] aliases: [ disctotal, totaldiscs ]
album: true album: true
discsubtitle: discsubtitle:
aliases: [ tsst, discsubtitle, ----:com.apple.itunes:discsubtitle, wm/setsubtitle ] aliases: [ tsst, discsubtitle, ----:com.apple.itunes:discsubtitle, setsubtitle, wm/setsubtitle ]
bpm: bpm:
aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ] aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ]
lyrics: 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 // Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later
for i := range entry.artists { 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 { if err != nil {
log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err) log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err)
return 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 the .ndignore file is empty, mimic the current behavior and ignore everything
if len(newPatterns) == 0 { if len(newPatterns) == 0 {
log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", currentFolder)
newPatterns = []string{"**/*"} 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 // 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)) children = make([]string, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
entryPath := path.Join(dirPath, entry.Name()) 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) log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath)
continue continue
} }
@ -309,6 +312,10 @@ func isEntryIgnored(name string) bool {
return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..")
} }
func isScanIgnored(matcher *ignore.GitIgnore, entryPath string) bool { func isScanIgnored(ctx context.Context, matcher *ignore.GitIgnore, entryPath string) bool {
return matcher.MatchesPath(entryPath) 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) log.Error(ctx, "Watcher: Error watching library", "library", lib.ID, "path", lib.Path, err)
return 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 { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case path := <-c: case path := <-c:
path, err = filepath.Rel(lib.Path, path) path, err = filepath.Rel(absLibPath, path)
if err != nil { 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 continue
} }
if isIgnoredPath(ctx, fsys, path) { if isIgnoredPath(ctx, fsys, path) {
log.Trace(ctx, "Watcher: Ignoring change", "library", lib.ID, "path", path) log.Trace(ctx, "Watcher: Ignoring change", "library", lib.ID, "path", path)
continue 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{}{} watchChan <- struct{}{}
} }
} }

View File

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

View File

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

View File

@ -77,11 +77,26 @@ func sortName(sortName, orderName string) string {
return orderName 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 { func toArtist(r *http.Request, a model.Artist) responses.Artist {
artist := responses.Artist{ artist := responses.Artist{
Id: a.ID, Id: a.ID,
Name: a.Name, Name: a.Name,
AlbumCount: int32(a.AlbumCount),
UserRating: int32(a.Rating), UserRating: int32(a.Rating),
CoverArt: a.CoverArtID().String(), CoverArt: a.CoverArtID().String(),
ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600), ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600),
@ -96,7 +111,7 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 {
artist := responses.ArtistID3{ artist := responses.ArtistID3{
Id: a.ID, Id: a.ID,
Name: a.Name, Name: a.Name,
AlbumCount: int32(a.AlbumCount), AlbumCount: getArtistAlbumCount(a),
CoverArt: a.CoverArtID().String(), CoverArt: a.CoverArtID().String(),
ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600), ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600),
UserRating: int32(a.Rating), UserRating: int32(a.Rating),

View File

@ -10,6 +10,10 @@ import (
) )
var _ = Describe("helpers", func() { var _ = Describe("helpers", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
Describe("fakePath", func() { Describe("fakePath", func() {
var mf model.MediaFile var mf model.MediaFile
BeforeEach(func() { BeforeEach(func() {
@ -134,4 +138,29 @@ var _ = Describe("helpers", func() {
Entry("returns \"explicit\" when the db value is \"e\"", "e", "explicit"), 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 the db value is \"\"", "", ""),
Entry("returns an empty string when there are unexpected values on the db", "abc", "")) 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", "id": "111",
"name": "aaa", "name": "aaa",
"albumCount": 2,
"starred": "2016-03-02T20:30:00Z", "starred": "2016-03-02T20:30:00Z",
"userRating": 3, "userRating": 3,
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" "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"> <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"> <indexes lastModified="1" ignoredArticles="A">
<index name="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> </index>
</indexes> </indexes>
</subsonic-response> </subsonic-response>

View File

@ -92,7 +92,6 @@ 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 int32 `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 int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
@ -233,7 +232,7 @@ 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 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"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"` ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`

View File

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

View File

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