mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-04 09:18:39 +01:00
Merge remote-tracking branch 'origin/master' into pr5949
# Conflicts: # cmd/wire_gen.go
This commit is contained in:
commit
bd6a3fb16e
58 changed files with 1732 additions and 543 deletions
|
|
@ -1,10 +1,13 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
|
|
@ -27,6 +30,38 @@ type Library struct {
|
|||
TotalSize int64 `json:"totalSize" db:"total_size"`
|
||||
TotalDuration float64 `json:"totalDuration" db:"total_duration"`
|
||||
DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"`
|
||||
PIDAlbum string `json:"pidAlbum" db:"pid_album"`
|
||||
PIDTrack string `json:"pidTrack" db:"pid_track"`
|
||||
ScannedPIDAlbum string `json:"-" db:"scanned_pid_album"`
|
||||
ScannedPIDTrack string `json:"-" db:"scanned_pid_track"`
|
||||
}
|
||||
|
||||
// PIDConfig holds the persistent ID specs used to compute track and album IDs.
|
||||
type PIDConfig struct {
|
||||
Track string
|
||||
Album string
|
||||
}
|
||||
|
||||
// EffectivePID returns the PID specs in effect for this library: its own overrides, falling back to
|
||||
// the global config.
|
||||
func (l Library) EffectivePID() PIDConfig {
|
||||
return PIDConfig{
|
||||
Track: cmp.Or(l.PIDTrack, conf.Server.PID.Track),
|
||||
Album: cmp.Or(l.PIDAlbum, conf.Server.PID.Album),
|
||||
}
|
||||
}
|
||||
|
||||
// PIDChanged reports whether the effective PID specs differ from the ones used by the last finished
|
||||
// scan of this library. A library that was never scanned counts as changed.
|
||||
func (l Library) PIDChanged() bool {
|
||||
pid := l.EffectivePID()
|
||||
return !strings.EqualFold(l.ScannedPIDAlbum, pid.Album) || !strings.EqualFold(l.ScannedPIDTrack, pid.Track)
|
||||
}
|
||||
|
||||
// NeedsPIDRescan reports whether the library has content imported with an old PID config, so it must be
|
||||
// rescanned in full. A library that never finished a scan has nothing to regroup.
|
||||
func (l Library) NeedsPIDRescan() bool {
|
||||
return !l.LastScanAt.IsZero() && l.PIDChanged()
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
@ -59,6 +94,8 @@ type LibraryRepository interface {
|
|||
// TODO These methods should be moved to a core service
|
||||
ScanBegin(ctx context.Context, id int, fullScan bool) error
|
||||
ScanEnd(ctx context.Context, id int) error
|
||||
// SetScannedPID records the PID specs used by the last finished scan of the library
|
||||
SetScannedPID(ctx context.Context, id int, pid PIDConfig) error
|
||||
ScanInProgress(ctx context.Context) (bool, error)
|
||||
RefreshStats(ctx context.Context, id int) error
|
||||
}
|
||||
|
|
|
|||
57
model/library_matcher.go
Normal file
57
model/library_matcher.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LibraryMatcher finds the library that contains an absolute path.
|
||||
type LibraryMatcher struct {
|
||||
libraries Libraries
|
||||
cleanedPaths []string
|
||||
}
|
||||
|
||||
// NewLibraryMatcher sorts the libraries longest path first, so /music-classical is checked before /music.
|
||||
func NewLibraryMatcher(libs Libraries) *LibraryMatcher {
|
||||
libs = slices.Clone(libs)
|
||||
slices.SortFunc(libs, func(i, j Library) int {
|
||||
return cmp.Compare(len(j.Path), len(i.Path))
|
||||
})
|
||||
cleanedPaths := make([]string, len(libs))
|
||||
for i, lib := range libs {
|
||||
cleanedPaths[i] = filepath.Clean(lib.Path)
|
||||
}
|
||||
return &LibraryMatcher{libraries: libs, cleanedPaths: cleanedPaths}
|
||||
}
|
||||
|
||||
// FindLibrary returns the library whose path contains absolutePath.
|
||||
func (lm *LibraryMatcher) FindLibrary(absolutePath string) (Library, bool) {
|
||||
for i, libPath := range lm.cleanedPaths {
|
||||
// A cleaned path only ends with a separator when it is a filesystem root
|
||||
if strings.HasPrefix(absolutePath, libPath) && (len(absolutePath) == len(libPath) ||
|
||||
absolutePath[len(libPath)] == filepath.Separator || strings.HasSuffix(libPath, string(filepath.Separator))) {
|
||||
return lm.libraries[i], true
|
||||
}
|
||||
}
|
||||
return Library{}, false
|
||||
}
|
||||
|
||||
// LibraryRelativePath rebases an absolute path onto the library root, as the scanner's io/fs sees it
|
||||
// (forward slashes). Relative paths, and absolute paths outside the library root, are returned unchanged.
|
||||
func LibraryRelativePath(libPath, path string) string {
|
||||
if !filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
// The library root may be relative (e.g. the default "./music"); it resolves against the same cwd
|
||||
absLib, err := filepath.Abs(libPath)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
rel, err := filepath.Rel(absLib, path)
|
||||
if err != nil || !filepath.IsLocal(rel) {
|
||||
return path
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
91
model/library_matcher_test.go
Normal file
91
model/library_matcher_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package model_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("LibraryMatcher", func() {
|
||||
// Paths are written Unix-style and converted, so they use the OS separator, as filepath.Abs output does
|
||||
find := func(libs model.Libraries, path string) int {
|
||||
for i := range libs {
|
||||
libs[i].Path = filepath.FromSlash(libs[i].Path)
|
||||
}
|
||||
lib, ok := model.NewLibraryMatcher(libs).FindLibrary(filepath.FromSlash(path))
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return lib.ID
|
||||
}
|
||||
|
||||
DescribeTable("matches the longest library path",
|
||||
func(libs model.Libraries, path string, expectedID int) {
|
||||
Expect(find(libs, path)).To(Equal(expectedID))
|
||||
},
|
||||
Entry("nested library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}, {ID: 3, Path: "/music-classical/opera"}}, "/music-classical/opera/subdir/track.mp3", 3),
|
||||
Entry("sibling with a shared prefix", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical/track.mp3", 2),
|
||||
Entry("shorter library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music/track.mp3", 1),
|
||||
Entry("exact library root", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical", 2),
|
||||
Entry("deeply nested libraries", model.Libraries{{ID: 1, Path: "/media"}, {ID: 2, Path: "/media/audio"}, {ID: 3, Path: "/media/audio/classical"}, {ID: 4, Path: "/media/audio/classical/baroque"}}, "/media/audio/classical/mozart/track.mp3", 3),
|
||||
Entry("prefix that is not a path boundary", model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/ab"}, {ID: 3, Path: "/abc"}}, "/ab/file.mp3", 2),
|
||||
Entry("special characters match literally", model.Libraries{{ID: 1, Path: "/music[test]"}, {ID: 2, Path: "/music(backup)"}}, "/music[test]/track.mp3", 1),
|
||||
Entry("library path with a trailing slash", model.Libraries{{ID: 1, Path: "/music/"}}, "/music/track.mp3", 1),
|
||||
Entry("library at the filesystem root", model.Libraries{{ID: 1, Path: "/"}}, "/music/track.mp3", 1),
|
||||
Entry("nested library under a root library", model.Libraries{{ID: 1, Path: "/"}, {ID: 2, Path: "/music"}}, "/music/track.mp3", 2),
|
||||
)
|
||||
|
||||
It("does not match a path outside every library", func() {
|
||||
Expect(find(model.Libraries{{ID: 1, Path: "/music"}}, "/music-backup/track.mp3")).To(BeZero())
|
||||
})
|
||||
|
||||
It("does not match anything without libraries", func() {
|
||||
Expect(find(nil, "/music/track.mp3")).To(BeZero())
|
||||
})
|
||||
|
||||
It("does not reorder the caller's libraries", func() {
|
||||
libs := model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/abc"}}
|
||||
model.NewLibraryMatcher(libs)
|
||||
Expect(libs.IDs()).To(Equal([]int{1, 2}))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("LibraryRelativePath", func() {
|
||||
// Paths are built with filepath so the "absolute" cases stay absolute on every OS
|
||||
// (a Unix-style "/foo" is not absolute on Windows).
|
||||
libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection"))
|
||||
outside, _ := filepath.Abs(filepath.Join("somewhere", "else"))
|
||||
|
||||
It("returns a relative path unchanged", func() {
|
||||
Expect(model.LibraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection"))
|
||||
})
|
||||
|
||||
It("rebases an absolute target when the library root is relative", func() {
|
||||
cwd, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(model.LibraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock"))
|
||||
})
|
||||
|
||||
It("rebases an absolute path that equals the library root to '.'", func() {
|
||||
Expect(model.LibraryRelativePath(libRoot, libRoot)).To(Equal("."))
|
||||
})
|
||||
|
||||
It("rebases an absolute path under the library root", func() {
|
||||
Expect(model.LibraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
|
||||
})
|
||||
|
||||
It("handles a trailing slash on the library path", func() {
|
||||
Expect(model.LibraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
|
||||
})
|
||||
|
||||
It("leaves an absolute path outside the library root unchanged", func() {
|
||||
Expect(model.LibraryRelativePath(libRoot, outside)).To(Equal(outside))
|
||||
})
|
||||
|
||||
It("returns an empty path unchanged", func() {
|
||||
Expect(model.LibraryRelativePath(libRoot, "")).To(Equal(""))
|
||||
})
|
||||
})
|
||||
73
model/library_test.go
Normal file
73
model/library_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package model_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Library PID config", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.PID.Album = "global_album"
|
||||
conf.Server.PID.Track = "global_track"
|
||||
})
|
||||
|
||||
Describe("EffectivePID", func() {
|
||||
It("falls back to the global config", func() {
|
||||
Expect(model.Library{}.EffectivePID()).To(Equal(model.PIDConfig{Track: "global_track", Album: "global_album"}))
|
||||
})
|
||||
It("uses the library overrides", func() {
|
||||
lib := model.Library{PIDAlbum: "folder", PIDTrack: "title"}
|
||||
Expect(lib.EffectivePID()).To(Equal(model.PIDConfig{Track: "title", Album: "folder"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PIDChanged", func() {
|
||||
It("is false when the scanned specs match, ignoring case", func() {
|
||||
lib := model.Library{ScannedPIDAlbum: "GLOBAL_ALBUM", ScannedPIDTrack: "global_track"}
|
||||
Expect(lib.PIDChanged()).To(BeFalse())
|
||||
})
|
||||
It("is true when the album override differs from the scanned spec", func() {
|
||||
lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"}
|
||||
Expect(lib.PIDChanged()).To(BeTrue())
|
||||
})
|
||||
It("is true when only the track spec changed", func() {
|
||||
lib := model.Library{PIDTrack: "title", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"}
|
||||
Expect(lib.PIDChanged()).To(BeTrue())
|
||||
})
|
||||
It("is true when the global config changed for a library without overrides", func() {
|
||||
lib := model.Library{ScannedPIDAlbum: "old_album", ScannedPIDTrack: "global_track"}
|
||||
Expect(lib.PIDChanged()).To(BeTrue())
|
||||
})
|
||||
It("is true for a library that was never scanned", func() {
|
||||
Expect(model.Library{}.PIDChanged()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NeedsPIDRescan", func() {
|
||||
It("is false for a library that never finished a scan", func() {
|
||||
Expect(model.Library{PIDAlbum: "folder"}.NeedsPIDRescan()).To(BeFalse())
|
||||
})
|
||||
It("is true for a scanned library whose PID config changed", func() {
|
||||
lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()}
|
||||
Expect(lib.NeedsPIDRescan()).To(BeTrue())
|
||||
})
|
||||
It("is false for a scanned library whose PID config did not change", func() {
|
||||
lib := model.Library{ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()}
|
||||
Expect(lib.NeedsPIDRescan()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
It("does not expose the scanned specs in JSON", func() {
|
||||
data, err := json.Marshal(model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "secret_album", ScannedPIDTrack: "secret_track"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(ContainSubstring(`"pidAlbum":"folder"`))
|
||||
Expect(string(data)).ToNot(ContainSubstring("secret_"))
|
||||
})
|
||||
})
|
||||
|
|
@ -8,15 +8,14 @@ import (
|
|||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile {
|
||||
mf := model.MediaFile{
|
||||
LibraryID: libID,
|
||||
LibraryID: lib.ID,
|
||||
FolderID: folderID,
|
||||
Tags: maps.Clone(md.tags),
|
||||
}
|
||||
|
|
@ -84,8 +83,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
|||
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
|
||||
|
||||
// Persistent IDs
|
||||
mf.PID = md.trackPID(mf)
|
||||
mf.AlbumID = md.albumID(mf, conf.Server.PID.Album)
|
||||
pid := lib.EffectivePID()
|
||||
mf.PID = md.trackPID(mf, pid)
|
||||
mf.AlbumID = md.albumID(mf, pid.Album)
|
||||
|
||||
// BFR These IDs will go away once the UI handle multiple participants.
|
||||
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers
|
||||
|
|
|
|||
|
|
@ -30,9 +30,23 @@ var _ = Describe("ToMediaFile", func() {
|
|||
var toMediaFile = func(tags model.RawTags) model.MediaFile {
|
||||
props.Tags = tags
|
||||
md = metadata.New("filepath", props)
|
||||
return md.ToMediaFile(1, "folderID")
|
||||
return md.ToMediaFile(model.Library{ID: 1}, "folderID")
|
||||
}
|
||||
|
||||
Describe("Persistent IDs", func() {
|
||||
It("uses the library PID config for the album ID and for albumid in the track spec", func() {
|
||||
props.Tags = model.RawTags{"ALBUM": {"Kind of Blue"}, "TITLE": {"So What"}}
|
||||
md = metadata.New("Jazz/Loose/01.mp3", props)
|
||||
|
||||
byTags := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "album", PIDTrack: "albumid,title"}, "folderID")
|
||||
byFolder := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}, "folderID")
|
||||
|
||||
Expect(byFolder.AlbumID).ToNot(Equal(byTags.AlbumID))
|
||||
Expect(byFolder.AlbumID).To(Equal(md.AlbumID(byFolder, "folder")))
|
||||
Expect(byFolder.PID).ToNot(Equal(byTags.PID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Dates", func() {
|
||||
It("should parse properly tagged dates ", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ var _ = Describe("Participants", func() {
|
|||
var toMediaFile = func(tags model.RawTags) model.MediaFile {
|
||||
props.Tags = tags
|
||||
md = metadata.New("filepath", props)
|
||||
return md.ToMediaFile(1, "folderID")
|
||||
return md.ToMediaFile(model.Library{ID: 1}, "folderID")
|
||||
}
|
||||
|
||||
Describe("ARTIST(S) tags", func() {
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ var _ = Describe("Metadata", func() {
|
|||
tag: {tagValue},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
return md.ToMediaFile(0, "0")
|
||||
return md.ToMediaFile(model.Library{}, "0")
|
||||
}
|
||||
|
||||
DescribeTable("Gain",
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ package metadata
|
|||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -22,12 +22,13 @@ type hashFunc = func(...string) string
|
|||
// attributes. Attributes can be either tags or processed values like folder,
|
||||
// albumid, albumartistid, etc. For each field, it gets all its attribute values
|
||||
// and concatenates them, then hashes the result. If a field is empty, it is
|
||||
// skipped and the function looks for the next field.
|
||||
// skipped and the function looks for the next field. albumSpec is the album PID
|
||||
// spec used to resolve the `albumid` attribute.
|
||||
//
|
||||
// Taking hash as a parameter (instead of closing over it in a factory) keeps
|
||||
// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the
|
||||
// heap on every call.
|
||||
func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string {
|
||||
func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prependLibId bool, hash hashFunc) string {
|
||||
switch spec {
|
||||
case "track_legacy":
|
||||
return legacyTrackID(mf, prependLibId)
|
||||
|
|
@ -41,7 +42,7 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool,
|
|||
values := make([]string, len(attributes))
|
||||
hasValue := false
|
||||
for i, attr := range attributes {
|
||||
v := getPIDAttr(mf, md, attr, prependLibId, spec, hash)
|
||||
v := getPIDAttr(mf, md, attr, prependLibId, spec, albumSpec, hash)
|
||||
if v != "" {
|
||||
hasValue = true
|
||||
}
|
||||
|
|
@ -58,15 +59,15 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool,
|
|||
return hash(pid)
|
||||
}
|
||||
|
||||
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string {
|
||||
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string {
|
||||
attr = strings.TrimSpace(strings.ToLower(attr))
|
||||
switch attr {
|
||||
case "albumid":
|
||||
if spec == conf.Server.PID.Album {
|
||||
if spec == albumSpec {
|
||||
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
|
||||
return ""
|
||||
}
|
||||
return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash)
|
||||
return computePID(mf, md, albumSpec, albumSpec, prependLibId, hash)
|
||||
case "folder":
|
||||
return filepath.Dir(mf.Path)
|
||||
case "albumartistid":
|
||||
|
|
@ -79,18 +80,50 @@ func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool,
|
|||
return md.String(model.TagName(attr))
|
||||
}
|
||||
|
||||
func (md Metadata) trackPID(mf model.MediaFile) string {
|
||||
return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash)
|
||||
// ValidatePIDSpec checks a PID override before it is stored; empty means "use the global config".
|
||||
// Aliases resolve to empty at scan time: accepted only in track specs, because the default one uses them.
|
||||
func ValidatePIDSpec(spec string, isAlbum bool) error {
|
||||
switch {
|
||||
case spec == "", isAlbum && spec == "album_legacy", !isAlbum && spec == "track_legacy":
|
||||
return nil
|
||||
}
|
||||
for field := range strings.SplitSeq(spec, "|") {
|
||||
for attr := range strings.SplitSeq(field, ",") {
|
||||
attr = strings.TrimSpace(strings.ToLower(attr))
|
||||
switch attr {
|
||||
case "":
|
||||
return fmt.Errorf("empty attribute in %q", spec)
|
||||
case "albumid":
|
||||
if isAlbum {
|
||||
return errors.New("albumid cannot be used in an album PID")
|
||||
}
|
||||
case "folder", "albumartistid":
|
||||
default:
|
||||
name, ok := model.CanonicalTagName(attr)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown attribute %q", attr)
|
||||
}
|
||||
if isAlbum && string(name) != attr {
|
||||
return fmt.Errorf("use the tag name %q instead of its alias %q", name, attr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (md Metadata) trackPID(mf model.MediaFile, pid model.PIDConfig) string {
|
||||
return computePID(mf, md, pid.Track, pid.Album, true, id.NewHash)
|
||||
}
|
||||
|
||||
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
|
||||
return computePID(mf, md, pidConf, true, id.NewHash)
|
||||
return computePID(mf, md, pidConf, pidConf, true, id.NewHash)
|
||||
}
|
||||
|
||||
// BFR Must be configurable?
|
||||
func (md Metadata) artistID(name string) string {
|
||||
mf := model.MediaFile{AlbumArtist: name}
|
||||
return computePID(mf, md, "albumartistid", false, id.NewHash)
|
||||
return computePID(mf, md, "albumartistid", "", false, id.NewHash)
|
||||
}
|
||||
|
||||
func (md Metadata) mapTrackTitle() string {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ package metadata
|
|||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
|
@ -13,16 +12,18 @@ import (
|
|||
|
||||
var _ = Describe("getPID", func() {
|
||||
var (
|
||||
md Metadata
|
||||
mf model.MediaFile
|
||||
sum hashFunc
|
||||
md Metadata
|
||||
mf model.MediaFile
|
||||
sum hashFunc
|
||||
albumSpec string
|
||||
)
|
||||
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
||||
return computePID(mf, md, spec, prependLibId, sum)
|
||||
return computePID(mf, md, spec, albumSpec, prependLibId, sum)
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
|
||||
albumSpec = consts.DefaultAlbumPID
|
||||
})
|
||||
|
||||
Context("attributes are tags", func() {
|
||||
|
|
@ -66,8 +67,7 @@ var _ = Describe("getPID", func() {
|
|||
|
||||
Context("calculated attributes", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
||||
albumSpec = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
||||
})
|
||||
When("field is title", func() {
|
||||
It("should return the pid", func() {
|
||||
|
|
@ -121,8 +121,8 @@ var _ = Describe("getPID", func() {
|
|||
When("albumid configuration refers to albumid recursively", func() {
|
||||
It("should avoid infinite recursion", func() {
|
||||
// Reproduce the issue from #4920
|
||||
conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
|
||||
spec := conf.Server.PID.Album
|
||||
albumSpec = "albumid,album,albumversion,releasedate"
|
||||
spec := albumSpec
|
||||
md.tags = map[model.TagName][]string{
|
||||
"album": {"Album Name"},
|
||||
"albumversion": {"Version"},
|
||||
|
|
@ -205,8 +205,7 @@ var _ = Describe("getPID", func() {
|
|||
})
|
||||
When("prependLibId is true with nested albumid", func() {
|
||||
It("should handle nested albumid calls correctly", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.PID.Album = "album"
|
||||
albumSpec = "album"
|
||||
spec := "albumid"
|
||||
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
|
||||
mf.AlbumArtist = "Test Artist"
|
||||
|
|
@ -306,3 +305,34 @@ var _ = Describe("getPID", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ValidatePIDSpec", func() {
|
||||
DescribeTable("accepts valid specs",
|
||||
func(spec string, isAlbum bool) {
|
||||
Expect(ValidatePIDSpec(spec, isAlbum)).To(Succeed())
|
||||
},
|
||||
Entry("empty, meaning the global config", "", true),
|
||||
Entry("default album spec", consts.DefaultAlbumPID, true),
|
||||
Entry("default track spec, which uses tag aliases", consts.DefaultTrackPID, false),
|
||||
Entry("folder", "folder", true),
|
||||
Entry("album legacy", "album_legacy", true),
|
||||
Entry("track legacy", "track_legacy", false),
|
||||
Entry("computed attributes", "albumartistid,album|title", true),
|
||||
Entry("albumid in a track spec", "albumid,title", false),
|
||||
Entry("spaces and mixed case", "MusicBrainz_AlbumID | Folder", true),
|
||||
)
|
||||
|
||||
DescribeTable("rejects invalid specs",
|
||||
func(spec string, isAlbum bool, msg string) {
|
||||
Expect(ValidatePIDSpec(spec, isAlbum)).To(MatchError(ContainSubstring(msg)))
|
||||
},
|
||||
Entry("unknown tag", "albmversion", true, `unknown attribute "albmversion"`),
|
||||
Entry("empty field", "album||title", true, "empty attribute"),
|
||||
Entry("empty attribute", "album,,title", true, "empty attribute"),
|
||||
Entry("trailing separator", "album|", true, "empty attribute"),
|
||||
Entry("albumid in an album spec", "albumid,album", true, "albumid"),
|
||||
Entry("tag alias in an album spec", "talb", true, `use the tag name "album" instead of its alias "talb"`),
|
||||
Entry("track legacy in an album spec", "track_legacy", true, `unknown attribute "track_legacy"`),
|
||||
Entry("album legacy in a track spec", "album_legacy", false, `unknown attribute "album_legacy"`),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ package model
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrAlreadyScanning = errors.New("already scanning")
|
||||
|
||||
// ScanTarget represents a specific folder within a library to be scanned.
|
||||
// NOTE: This struct is used as a map key, so it should only contain comparable types.
|
||||
type ScanTarget struct {
|
||||
|
|
|
|||
|
|
@ -195,6 +195,28 @@ func TagMappings() map[TagName]TagConf {
|
|||
return mappings
|
||||
}
|
||||
|
||||
// CanonicalTagName returns the mapped tag that name is, or is an alias of. Tags are stored under this name.
|
||||
func CanonicalTagName(name string) (TagName, bool) {
|
||||
tagName, ok := tagNameIndex()[TagName(name).ToLower()]
|
||||
return tagName, ok
|
||||
}
|
||||
|
||||
// tagNameIndex maps every tag name and alias to its tag name. Names are added last, so they win over aliases
|
||||
// (musicbrainz_trackid is a tag and also an alias of musicbrainz_recordingid).
|
||||
var tagNameIndex = sync.OnceValue(func() map[TagName]TagName {
|
||||
mappings := TagMappings()
|
||||
index := make(map[TagName]TagName, len(mappings))
|
||||
for name, tag := range mappings {
|
||||
for _, alias := range tag.Aliases {
|
||||
index[TagName(alias)] = name
|
||||
}
|
||||
}
|
||||
for name := range mappings {
|
||||
index[name] = name
|
||||
}
|
||||
return index
|
||||
})
|
||||
|
||||
func TagRolesConf() TagConf {
|
||||
_, cfg := parseMappings()
|
||||
return cfg.Roles
|
||||
|
|
|
|||
|
|
@ -192,3 +192,22 @@ var _ = Describe("TagConf", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("CanonicalTagName", func() {
|
||||
DescribeTable("resolves tag names and aliases",
|
||||
func(name string, expected TagName) {
|
||||
tagName, ok := CanonicalTagName(name)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(tagName).To(Equal(expected))
|
||||
},
|
||||
Entry("tag name", "album", TagAlbum),
|
||||
Entry("alias", "talb", TagAlbum),
|
||||
Entry("mixed case alias", "TALB", TagAlbum),
|
||||
Entry("tag name that is also an alias of another tag", "musicbrainz_trackid", TagMusicBrainzTrackID),
|
||||
)
|
||||
|
||||
It("does not resolve an unknown name", func() {
|
||||
_, ok := CanonicalTagName("nosuchtag")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue