mirror of
https://github.com/navidrome/navidrome.git
synced 2025-05-13 16:46:36 +03:00
Merge branch 'master' into dlna-spike
This commit is contained in:
commit
f6d4295d42
@ -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)
|
||||||
|
@ -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.
|
// 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
|
||||||
f := model.NewFolder(lib, path)
|
if !d.IsDir() {
|
||||||
_, err = stmt.ExecContext(ctx, f.ID, lib.ID, f.Path, f.Name, f.ParentID)
|
return nil
|
||||||
if err != nil {
|
}
|
||||||
log.Error("error writing folder to DB", "path", path, err)
|
|
||||||
}
|
// 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
|
return err
|
||||||
})
|
})
|
||||||
|
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
@ -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 {
|
||||||
|
@ -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"
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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})
|
||||||
@ -118,11 +118,15 @@ 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),
|
||||||
Like{"value": genre},
|
})
|
||||||
NotEq{"atom": nil},
|
}
|
||||||
}),
|
|
||||||
|
func filterByGenre(genre string) Sqlizer {
|
||||||
|
return persistence.Exists("json_tree(tags)", And{
|
||||||
|
Like{"value": genre},
|
||||||
|
NotEq{"atom": nil},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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),
|
||||||
|
@ -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)))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -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"
|
||||||
|
@ -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>
|
||||||
|
@ -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"`
|
||||||
|
@ -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)
|
||||||
|
@ -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),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user