Compare commits

...
18 Commits
Author SHA1 Message Date
Deluan b97d6d29fd refactor(ui): render DocLink with MUI Link and use it for the insights docs
DocLink rendered a plain <a>, so it ignored the theme and showed as default browser blue. It now renders a Material-UI Link and forwards extra props, so callers can pass style and similar attributes.

The insights docs link was the last hardcoded docs URL. INSIGHTS_DOC_URL is replaced by INSIGHTS_DOC_PATH, and both the About dialog and the first-admin signup notice render it through DocLink. As a side effect the About dialog link now opens in a new tab, like the other external links there.
2026-09-10 23:32:18 -04:00
Deluan 1c5a8d747c refactor(ui): use the DocLink component for the persistent IDs docs link
Replaces a hardcoded URL duplicated in both library forms.
2026-09-09 15:26:33 -04:00
Deluan 5907594f7d refactor(tests): move fakeFileInfo type definition to the end of persistent_ids_test.go 2026-09-09 15:26:33 -04:00
Deluan 769939a657 refactor(metadata): unexport pidSpec
Nothing outside model/metadata references it, and no exported signature
carries it.
2026-09-09 15:26:33 -04:00
Deluan 84b6fb2239 refactor(scanner): group PIDConfChanged with the other startup scan decisions
Both PIDConfChanged and EffectiveFullScan answer a startup question for
cmd, over the same ds.Library(ctx).GetAll() fold. Keeping them in one
file makes that pairing visible.
2026-09-09 15:26:33 -04:00
Deluan 95ec20d137 fix(ui): keep pidAlbum/pidTrack pristine after reverting to original value
react-final-form's default parse turns '' into undefined, deleting the key
from values and leaving the field permanently dirty against a '' initialValue.
Add parsePidField to keep empty PID fields as '' through onChange.
2026-09-09 15:26:33 -04:00
Deluan 19e40f7265 fix(ui): register pidAlbum field always and rename PID section to Persistent IDs
Album Grouping changes in Default/Folder mode never registered pidAlbum
with react-final-form, so the form stayed pristine and Save stayed
disabled. Switch to useField() to keep it registered in every mode.

Also renames the "Metadata" section to "Persistent IDs" and adds a
documentation link, per live user testing feedback.
2026-09-09 15:26:33 -04:00
Deluan e8858b3451 test(scanner): cover track-only PID mismatch in PIDConfChanged
The existing specs only diverged on the album side, so a copy-paste
bug comparing Album twice would have passed unnoticed.
2026-09-09 15:26:33 -04:00
Deluan 64831b233e fix(library): compare effective PID specs, not raw override columns
Comparing raw PIDAlbum/PIDTrack triggered a full scan whenever a user
typed the current global default into an inherited library's override,
even though nothing effectively changed, and misfired on partial PUTs
that omit those fields. Compare EffectivePIDAlbum/Track case-insensitively,
matching scanner.PIDConfChanged, and fall back to the stored value when a
partial update doesn't include the PID columns.
2026-09-09 15:26:33 -04:00
Deluan c8a59f38b9 chore(ui): fix prettier formatting and trim bug-history comment
Two library files had unformatted JSX that make lintall's prettier
check rejects. Also trims a test comment that narrated bug history
instead of explaining the mock's rationale.
2026-09-09 15:26:33 -04:00
Deluan 04a0e63078 fix(ui): wire the real record into the PID save guard, harden its tests
FormWithRedirect must be <Edit>'s direct child to receive the fetched
record via react-admin's cloneElement injection; extracting it into
LibraryEditForm fixes a bug where the confirm dialog used an always-
undefined record. Also replaces two vacuous PIDAlbumInput assertions
and requires a non-empty value in custom mode so it can't silently
save as "inherit default".
2026-09-09 15:26:33 -04:00
Deluan dace5448b2 feat(ui): per-library PID settings with full-scan confirmation 2026-09-09 15:26:33 -04:00
Deluan 40f9c8a025 fix(library): tokenize pidAlbum recursion check instead of substring match
A substring check on "albumid" false-positived on the default spec,
which contains "musicbrainz_albumid". Reject only an exact "albumid" token.
2026-09-09 15:26:33 -04:00
Deluan 94db8b3f44 feat(library): full scan when a library's PID config changes
Also rejects an album PID spec that references albumid, since that
would create a circular dependency during scan.
2026-09-09 15:26:33 -04:00
Deluan 4ba073a8bc feat(scanner): detect PID changes per library at startup
Replaces the global property comparison in cmd/root.go with a
per-library check against each library's effective PID specs.
2026-09-09 15:26:33 -04:00
Deluan 8049e10bca feat(scanner): track PID specs per library instead of globally 2026-09-09 15:26:33 -04:00
Deluan 5f348e4503 refactor(metadata): pass PID specs explicitly instead of reading global config
ToMediaFile, computePID, and their helpers now take a PIDSpec built from
the library's effective PID settings, so per-library overrides can flow
through without touching global config from inside PID computation.
2026-09-09 15:26:33 -04:00
Deluan 83bb16f193 feat(library): add per-library PID columns and model helpers
Adds pid_album/pid_track overrides and scanned_pid_album/scanned_pid_track
tracking columns per library, migrating the existing global PID properties
into library defaults. Nothing consumes these yet.
2026-09-09 15:26:33 -04:00
37 changed files with 1360 additions and 285 deletions

No files matched your search

+1 -1
View File
@@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
return new(metadata.ToMediaFile(1, "folderID"))
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
}
BeforeEach(func() {
+1 -1
View File
@@ -66,7 +66,7 @@ func runInspector(args []string) {
log.Warn("Not an audio file", "file", filePath)
continue
}
output, err := core.Inspect(filePath, 1, "")
output, err := core.Inspect(filePath, model.Library{ID: 1}, "")
if err != nil {
log.Warn("Unable to process file", "file", filePath, "error", err)
continue
+1 -14
View File
@@ -15,7 +15,6 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/scheduler"
@@ -182,18 +181,6 @@ func schedulePeriodicScan(ctx context.Context) func() error {
}
}
func pidHashChanged(ds model.DataStore) (bool, error) {
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
if err != nil {
return false, err
}
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
if err != nil {
return false, err
}
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
}
// runInitialScan runs an initial scan of the music library if needed.
func runInitialScan(ctx context.Context) func() error {
return func() error {
@@ -206,7 +193,7 @@ func runInitialScan(ctx context.Context) func() error {
if err != nil {
return err
}
pidHasChanged, err := pidHashChanged(ds)
pidHasChanged, err := scanner.PIDConfChanged(ctx, ds)
if err != nil {
return err
}
+2 -2
View File
@@ -15,7 +15,7 @@ type InspectOutput struct {
MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
}
func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) {
func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) {
path, file := filepath.Split(filePath)
s, err := storage.For(path)
@@ -43,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
result := &InspectOutput{
File: filePath,
RawTags: tags[file].Tags,
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
MappedTags: new(md.ToMediaFile(lib, folderId)),
}
return result, nil
+41 -14
View File
@@ -7,6 +7,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
@@ -178,7 +179,7 @@ func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
}
if r.scanner != nil {
go r.triggerScan(lib, "new")
go r.triggerScan(lib, "new", false)
}
// Send library refresh event to all clients
@@ -209,24 +210,33 @@ func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string)
return r.mapError(err)
}
// A partial update that omits pidAlbum/pidTrack leaves them zero-valued on lib,
// even though Put() below won't touch those columns; use what will actually persist.
if len(cols) > 0 && !slices.Contains(cols, "pidAlbum") {
lib.PIDAlbum = originalLib.PIDAlbum
}
if len(cols) > 0 && !slices.Contains(cols, "pidTrack") {
lib.PIDTrack = originalLib.PIDTrack
}
pathChanged := originalLib.Path != lib.Path
pidChanged := !strings.EqualFold(originalLib.EffectivePIDAlbum(), lib.EffectivePIDAlbum()) ||
!strings.EqualFold(originalLib.EffectivePIDTrack(), lib.EffectivePIDTrack())
err = r.LibraryRepository.Put(lib, cols...)
if err != nil {
return r.mapError(err)
}
// Restart watcher and trigger scan if path was updated
if pathChanged {
if r.watcher != nil {
if err := r.watcher.Watch(r.ctx, lib); err != nil {
log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
// Watcher only cares about the path; a PID change alone doesn't need a restart
if pathChanged && r.watcher != nil {
if err := r.watcher.Watch(r.ctx, lib); err != nil {
log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
}
if r.scanner != nil {
go r.triggerScan(lib, "updated")
}
if (pathChanged || pidChanged) && r.scanner != nil {
go r.triggerScan(lib, "updated", pidChanged)
}
// Send library refresh event to all clients
@@ -270,7 +280,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
}
if r.scanner != nil {
go r.triggerScan(lib, "deleted")
go r.triggerScan(lib, "deleted", false)
}
// Send library refresh event to all clients
@@ -333,6 +343,10 @@ func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error
}
}
if hasRecursiveAlbumIDToken(library.PIDAlbum) {
validationErrors["pidAlbum"] = "resources.library.validation.pidAlbumRecursive"
}
if len(validationErrors) > 0 {
return &rest.ValidationError{Errors: validationErrors}
}
@@ -340,6 +354,19 @@ func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error
return nil
}
// hasRecursiveAlbumIDToken mirrors the spec tokenizing in model/metadata/persistent_ids.go
// (split on "|" then ",") so "musicbrainz_albumid" isn't flagged as the "albumid" token.
func hasRecursiveAlbumIDToken(spec string) bool {
for _, field := range strings.Split(spec, "|") {
for _, attr := range strings.Split(field, ",") {
if strings.TrimSpace(strings.ToLower(attr)) == "albumid" {
return true
}
}
}
return false
}
func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error {
// Validate path format
if !filepath.IsAbs(library.Path) {
@@ -407,10 +434,10 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
return nil
}
func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) {
log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string, fullScan bool) {
log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "fullScan", fullScan)
start := time.Now()
warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library
warnings, err := r.scanner.ScanAll(r.ctx, fullScan)
if err != nil {
log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
} else {
+150
View File
@@ -10,7 +10,9 @@ import (
"github.com/deluan/rest"
_ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
"github.com/navidrome/navidrome/model"
@@ -837,6 +839,154 @@ var _ = Describe("Library Service", func() {
})
})
Describe("PID changes", func() {
var repo rest.Persistable
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
})
It("rejects an album spec that references albumid", func() {
library := &model.Library{Name: "Test", Path: tempDir, PIDAlbum: "albumid,title"}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors).To(HaveKey("pidAlbum"))
})
It("accepts folder as an album spec", func() {
library := &model.Library{Name: "Test", Path: tempDir, PIDAlbum: "folder"}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
})
It("accepts the default album PID spec", func() {
library := &model.Library{Name: "Test", Path: tempDir, PIDAlbum: consts.DefaultAlbumPID}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
})
It("rejects a bare albumid spec", func() {
library := &model.Library{Name: "Test", Path: tempDir, PIDAlbum: "albumid"}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors).To(HaveKey("pidAlbum"))
})
It("accepts a spec where albumid is only a substring of another token", func() {
library := &model.Library{Name: "Test", Path: tempDir, PIDAlbum: "musicbrainz_albumid|album"}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
})
It("triggers a full scan when the album PID changes", func() {
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
library := &model.Library{ID: 1, Name: "Original Library", Path: tempDir, PIDAlbum: "folder"}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
calls := scanner.GetScanAllCalls()
Expect(calls[0].FullScan).To(BeTrue())
})
It("triggers a quick scan when only the path changes", func() {
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
newTempDir, err := os.MkdirTemp("", "navidrome-library-pid-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(newTempDir) })
library := &model.Library{ID: 1, Name: "Original Library", Path: newTempDir}
err = repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
calls := scanner.GetScanAllCalls()
Expect(calls[0].FullScan).To(BeFalse())
})
It("does not trigger a scan when the album PID override matches the effective default", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = consts.DefaultAlbumPID
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
library := &model.Library{ID: 1, Name: "Original Library", Path: tempDir, PIDAlbum: conf.Server.PID.Album}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
// No scan is spawned at all in this case, so there's no goroutine race to await:
// Consistently just confirms the count stays put over a short window.
Consistently(func() int {
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
It("triggers a full scan when clearing an override actually changes the effective spec", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "album_legacy"
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir, PIDAlbum: "folder"},
})
library := &model.Library{ID: 1, Name: "Original Library", Path: tempDir, PIDAlbum: ""}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
calls := scanner.GetScanAllCalls()
Expect(calls[0].FullScan).To(BeTrue())
})
It("does not treat pidAlbum as changed when a partial update omits it", func() {
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir, PIDAlbum: "folder"},
})
// Simulates a PUT body containing only "name": the decoded entity has a
// zero-valued PIDAlbum, but Put() below won't touch that column either.
library := &model.Library{ID: 1, Name: "Renamed Library", Path: tempDir}
err := repo.Update("1", library, "name")
Expect(err).NotTo(HaveOccurred())
Consistently(func() int {
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
})
Describe("Event Broadcasting", func() {
var repo rest.Persistable
@@ -0,0 +1,49 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/consts"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddPerLibraryPid, downAddPerLibraryPid)
}
func upAddPerLibraryPid(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
ALTER TABLE library ADD COLUMN pid_album TEXT NOT NULL DEFAULT '';
ALTER TABLE library ADD COLUMN pid_track TEXT NOT NULL DEFAULT '';
ALTER TABLE library ADD COLUMN scanned_pid_album TEXT NOT NULL DEFAULT '';
ALTER TABLE library ADD COLUMN scanned_pid_track TEXT NOT NULL DEFAULT '';
`)
if err != nil {
return err
}
// Seed from the global properties so existing installs don't full-scan on upgrade
_, err = tx.ExecContext(ctx, `
UPDATE library SET
scanned_pid_album = COALESCE((SELECT value FROM property WHERE id = ?), ?),
scanned_pid_track = COALESCE((SELECT value FROM property WHERE id = ?), ?);
`, consts.PIDAlbumKey, consts.DefaultAlbumPID, consts.PIDTrackKey, consts.DefaultTrackPID)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `DELETE FROM property WHERE id IN (?, ?);`,
consts.PIDAlbumKey, consts.PIDTrackKey)
return err
}
func downAddPerLibraryPid(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
ALTER TABLE library DROP COLUMN pid_album;
ALTER TABLE library DROP COLUMN pid_track;
ALTER TABLE library DROP COLUMN scanned_pid_album;
ALTER TABLE library DROP COLUMN scanned_pid_track;
`)
return err
}
+18
View File
@@ -1,8 +1,10 @@
package model
import (
"cmp"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -25,6 +27,10 @@ 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"`
}
const (
@@ -38,6 +44,16 @@ func (l Libraries) IDs() []int {
return slice.Map(l, func(lib Library) int { return lib.ID })
}
// EffectivePIDAlbum returns the library's album PID override, falling back to the global config.
func (l Library) EffectivePIDAlbum() string {
return cmp.Or(l.PIDAlbum, conf.Server.PID.Album)
}
// EffectivePIDTrack returns the library's track PID override, falling back to the global config.
func (l Library) EffectivePIDTrack() string {
return cmp.Or(l.PIDTrack, conf.Server.PID.Track)
}
type LibraryRepository interface {
Get(id int) (*Library, error)
// GetPath returns the path of the library with the given ID.
@@ -46,6 +62,8 @@ type LibraryRepository interface {
GetAll(...QueryOptions) (Libraries, error)
CountAll(...QueryOptions) (int64, error)
Put(l *Library, colsToUpdate ...string) error
// UpdateScannedPIDs records the PID specs used by the last completed scan.
UpdateScannedPIDs(id int, album, track string) error
Delete(id int) error
StoreMusicFolder() error
AddArtist(id int, artistID string) error
+41
View File
@@ -0,0 +1,41 @@
package model_test
import (
"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", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "album_global"
conf.Server.PID.Track = "track_global"
})
Describe("EffectivePIDAlbum", func() {
It("returns the global config when the override is empty", func() {
lib := model.Library{}
Expect(lib.EffectivePIDAlbum()).To(Equal("album_global"))
})
It("returns the override when it is set", func() {
lib := model.Library{PIDAlbum: "folder"}
Expect(lib.EffectivePIDAlbum()).To(Equal("folder"))
})
})
Describe("EffectivePIDTrack", func() {
It("returns the global config when the override is empty", func() {
lib := model.Library{}
Expect(lib.EffectivePIDTrack()).To(Equal("track_global"))
})
It("returns the override when it is set", func() {
lib := model.Library{PIDTrack: "title"}
Expect(lib.EffectivePIDTrack()).To(Equal("title"))
})
})
})
+6 -6
View File
@@ -8,15 +8,15 @@ 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 {
pids := pidSpec{Album: lib.EffectivePIDAlbum(), Track: lib.EffectivePIDTrack()}
mf := model.MediaFile{
LibraryID: libID,
LibraryID: lib.ID,
FolderID: folderID,
Tags: maps.Clone(md.tags),
}
@@ -84,8 +84,8 @@ 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)
mf.PID = md.trackPID(mf, pids)
mf.AlbumID = md.albumID(mf, pids)
// 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
@@ -112,7 +112,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
}
func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string {
return md.albumID(mf, pidConf)
return md.albumID(mf, pidSpec{Album: pidConf})
}
func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
+1 -1
View File
@@ -30,7 +30,7 @@ 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("Dates", func() {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -319,7 +319,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",
+16 -11
View File
@@ -6,7 +6,6 @@ import (
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -17,6 +16,12 @@ import (
type hashFunc = func(...string) string
// pidSpec carries the album and track PID specs in effect for one library.
type pidSpec struct {
Album string
Track string
}
// computePID calculates the persistent ID for a given spec. The spec is a
// pipe-separated list of fields, where each field is a comma-separated list of
// attributes. Attributes can be either tags or processed values like folder,
@@ -27,7 +32,7 @@ type hashFunc = func(...string) string
// 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 string, pids pidSpec, prependLibId bool, hash hashFunc) string {
switch spec {
case "track_legacy":
return legacyTrackID(mf, prependLibId)
@@ -41,7 +46,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, pids, prependLibId, spec, hash)
if v != "" {
hasValue = true
}
@@ -58,15 +63,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, pids pidSpec, prependLibId bool, spec string, hash hashFunc) string {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
if spec == conf.Server.PID.Album {
if spec == pids.Album {
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, pids.Album, pids, prependLibId, hash)
case "folder":
return filepath.Dir(mf.Path)
case "albumartistid":
@@ -79,18 +84,18 @@ 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)
func (md Metadata) trackPID(mf model.MediaFile, pids pidSpec) string {
return computePID(mf, md, pids.Track, pids, true, id.NewHash)
}
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
return computePID(mf, md, pidConf, true, id.NewHash)
func (md Metadata) albumID(mf model.MediaFile, pids pidSpec) string {
return computePID(mf, md, pids.Album, pids, 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", pidSpec{}, false, id.NewHash)
}
func (md Metadata) mapTrackTitle() string {
+60 -1
View File
@@ -1,10 +1,13 @@
package metadata
import (
"io/fs"
"strings"
"time"
"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"
@@ -18,7 +21,8 @@ var _ = Describe("getPID", func() {
sum hashFunc
)
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
return computePID(mf, md, spec, prependLibId, sum)
pids := pidSpec{Album: conf.Server.PID.Album, Track: conf.Server.PID.Track}
return computePID(mf, md, spec, pids, prependLibId, sum)
}
BeforeEach(func() {
@@ -305,4 +309,59 @@ var _ = Describe("getPID", func() {
})
})
})
Describe("per-library PID specs", func() {
var md Metadata
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = consts.DefaultAlbumPID
conf.Server.PID.Track = consts.DefaultTrackPID
md = New("/music/rock/artist/album/01 - track.mp3", Info{
FileInfo: fakeFileInfo{},
Tags: map[string][]string{
"album": {"An Album"},
"albumartist": {"An Artist"},
"title": {"A Track"},
"tracknumber": {"1"},
},
})
})
It("uses the library's album spec instead of the global config", func() {
tagLib := model.Library{ID: 1}
folderLib := model.Library{ID: 1, PIDAlbum: "folder"}
Expect(md.ToMediaFile(folderLib, "f1").AlbumID).
ToNot(Equal(md.ToMediaFile(tagLib, "f1").AlbumID))
})
It("uses the library's track spec instead of the global config", func() {
defaultLib := model.Library{ID: 1}
titleLib := model.Library{ID: 1, PIDTrack: "title"}
Expect(md.ToMediaFile(titleLib, "f1").PID).
ToNot(Equal(md.ToMediaFile(defaultLib, "f1").PID))
})
It("resolves albumid inside a track spec using the library's album spec", func() {
folderLib := model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}
tagLib := model.Library{ID: 1, PIDTrack: "albumid,title"}
Expect(md.ToMediaFile(folderLib, "f1").PID).
ToNot(Equal(md.ToMediaFile(tagLib, "f1").PID))
})
It("gives two libraries with the same spec different PIDs", func() {
Expect(md.ToMediaFile(model.Library{ID: 1}, "f1").AlbumID).
ToNot(Equal(md.ToMediaFile(model.Library{ID: 2}, "f1").AlbumID))
})
})
})
// fakeFileInfo satisfies FileInfo for tests that just need ToMediaFile to not panic.
type fakeFileInfo struct{ fs.FileInfo }
func (fakeFileInfo) ModTime() time.Time { return time.Time{} }
func (fakeFileInfo) Size() int64 { return 0 }
func (fakeFileInfo) BirthTime() time.Time { return time.Time{} }
+11
View File
@@ -94,6 +94,8 @@ func (r *libraryRepository) Put(l *model.Library, colsToUpdate ...string) error
"path": l.Path,
"remote_path": l.RemotePath,
"default_new_users": l.DefaultNewUsers,
"pid_album": l.PIDAlbum,
"pid_track": l.PIDTrack,
}, colsToUpdate...)
cols["updated_at"] = l.UpdatedAt
sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID})
@@ -132,6 +134,15 @@ ON CONFLICT (user_id, library_id) DO NOTHING;`,
return nil
}
func (r *libraryRepository) UpdateScannedPIDs(id int, album, track string) error {
sq := Update(r.tableName).
Set("scanned_pid_album", album).
Set("scanned_pid_track", track).
Where(Eq{"id": id})
_, err := r.executeSQL(sq)
return err
}
// TODO Remove this method when we have a proper UI to add libraries
// This is a temporary method to store the music folder path from the config in the DB
func (r *libraryRepository) StoreMusicFolder() error {
+47
View File
@@ -270,6 +270,53 @@ var _ = Describe("LibraryRepository", func() {
})
})
Describe("per-library PID columns", func() {
var newLibID int
BeforeEach(func() {
lib := model.Library{Name: "PID Test", Path: "/music/pidtest"}
Expect(repo.Put(&lib)).To(Succeed())
newLibID = lib.ID
})
It("round-trips pid_album and pid_track through Put", func() {
lib, err := repo.Get(newLibID)
Expect(err).ToNot(HaveOccurred())
lib.PIDAlbum = "folder"
lib.PIDTrack = "title"
Expect(repo.Put(lib)).To(Succeed())
got, err := repo.Get(newLibID)
Expect(err).ToNot(HaveOccurred())
Expect(got.PIDAlbum).To(Equal("folder"))
Expect(got.PIDTrack).To(Equal("title"))
})
It("does not let Put overwrite the scanned PID columns", func() {
Expect(repo.UpdateScannedPIDs(newLibID, "scanned_album", "scanned_track")).To(Succeed())
lib, err := repo.Get(newLibID)
Expect(err).ToNot(HaveOccurred())
lib.ScannedPIDAlbum = "clobbered"
lib.ScannedPIDTrack = "clobbered"
Expect(repo.Put(lib)).To(Succeed())
got, err := repo.Get(newLibID)
Expect(err).ToNot(HaveOccurred())
Expect(got.ScannedPIDAlbum).To(Equal("scanned_album"))
Expect(got.ScannedPIDTrack).To(Equal("scanned_track"))
})
It("writes the scanned PID columns via UpdateScannedPIDs", func() {
Expect(repo.UpdateScannedPIDs(newLibID, "a", "t")).To(Succeed())
got, err := repo.Get(newLibID)
Expect(err).ToNot(HaveOccurred())
Expect(got.ScannedPIDAlbum).To(Equal("a"))
Expect(got.ScannedPIDTrack).To(Equal("t"))
})
})
Describe("Delete", func() {
var adminRepo model.LibraryRepository
var artistRepo model.ArtistRepository
+17
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
@@ -314,6 +315,22 @@ func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, t
})
}
// PIDConfChanged reports whether any library's effective PID specs differ from
// the specs recorded by its last completed scan.
func PIDConfChanged(ctx context.Context, ds model.DataStore) (bool, error) {
libs, err := ds.Library(ctx).GetAll()
if err != nil {
return false, err
}
for _, lib := range libs {
if !strings.EqualFold(lib.ScannedPIDAlbum, lib.EffectivePIDAlbum()) ||
!strings.EqualFold(lib.ScannedPIDTrack, lib.EffectivePIDTrack()) {
return true, nil
}
}
return false, nil
}
func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []model.ScanTarget) bool {
return anyIncludedLibrary(ctx, s.ds, targets, func(library model.Library) bool {
return library.LastScanAt.IsZero()
+59
View File
@@ -3,6 +3,7 @@ package scanner_test
import (
"context"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork"
@@ -92,3 +93,61 @@ var _ = Describe("EffectiveFullScan", func() {
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse())
})
})
var _ = Describe("PIDConfChanged", func() {
var ds *tests.MockDataStore
ctx := context.Background()
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "album_spec"
conf.Server.PID.Track = "track_spec"
ds = &tests.MockDataStore{}
})
It("reports no change when every library matches its effective specs", func() {
repo := &tests.MockLibraryRepo{}
repo.SetData(model.Libraries{
{ID: 1, ScannedPIDAlbum: "album_spec", ScannedPIDTrack: "track_spec"},
})
ds.MockedLibrary = repo
Expect(scanner.PIDConfChanged(ctx, ds)).To(BeFalse())
})
It("reports a change when the global album spec differs from what was scanned", func() {
repo := &tests.MockLibraryRepo{}
repo.SetData(model.Libraries{
{ID: 1, ScannedPIDAlbum: "old_album", ScannedPIDTrack: "track_spec"},
})
ds.MockedLibrary = repo
Expect(scanner.PIDConfChanged(ctx, ds)).To(BeTrue())
})
It("reports a change when only one library has an override that was never scanned", func() {
repo := &tests.MockLibraryRepo{}
repo.SetData(model.Libraries{
{ID: 1, ScannedPIDAlbum: "album_spec", ScannedPIDTrack: "track_spec"},
{ID: 2, PIDAlbum: "folder", ScannedPIDAlbum: "album_spec", ScannedPIDTrack: "track_spec"},
})
ds.MockedLibrary = repo
Expect(scanner.PIDConfChanged(ctx, ds)).To(BeTrue())
})
It("reports a change when the track spec differs but the album spec matches", func() {
repo := &tests.MockLibraryRepo{}
repo.SetData(model.Libraries{
{ID: 1, ScannedPIDAlbum: "album_spec", ScannedPIDTrack: "old_track"},
})
ds.MockedLibrary = repo
Expect(scanner.PIDConfChanged(ctx, ds)).To(BeTrue())
})
It("ignores case differences", func() {
repo := &tests.MockLibraryRepo{}
repo.SetData(model.Libraries{
{ID: 1, ScannedPIDAlbum: "ALBUM_SPEC", ScannedPIDTrack: "TRACK_SPEC"},
})
ds.MockedLibrary = repo
Expect(scanner.PIDConfChanged(ctx, ds)).To(BeFalse())
})
})
+19 -24
View File
@@ -49,12 +49,13 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
}
type scanJob struct {
lib model.Library
fs storage.MusicFS
lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library
targetFolders []string // Specific folders to scan (including all descendants)
lock sync.Mutex
numFolders atomic.Int64
lib model.Library
fs storage.MusicFS
lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library
targetFolders []string // Specific folders to scan (including all descendants)
prevAlbumPIDConf string
lock sync.Mutex
numFolders atomic.Int64
}
func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) {
@@ -81,10 +82,11 @@ func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, full
lib.FullScanInProgress = lib.FullScanInProgress || fullScan
return &scanJob{
lib: lib,
fs: fsys,
lastUpdates: lastUpdates,
targetFolders: targetFolders,
lib: lib,
fs: fsys,
lastUpdates: lastUpdates,
targetFolders: targetFolders,
prevAlbumPIDConf: lib.ScannedPIDAlbum,
}, nil
}
@@ -120,12 +122,11 @@ func (j *scanJob) createFolderEntry(path string) *folderEntry {
// The phaseFolders struct implements the phase interface, providing methods to produce
// folder entries, process folders, persist changes to the database, and log the results.
type phaseFolders struct {
jobs []*scanJob
ds model.DataStore
ctx context.Context
state *scanState
prevAlbumPIDConf string
imageChanges *imageChangeCollector
jobs []*scanJob
ds model.DataStore
ctx context.Context
state *scanState
imageChanges *imageChangeCollector
}
func (p *phaseFolders) description() string {
@@ -134,12 +135,6 @@ func (p *phaseFolders) description() string {
func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
return ppl.NewProducer(func(put func(entry *folderEntry)) error {
var err error
p.prevAlbumPIDConf, err = p.ds.Property(p.ctx).DefaultGet(consts.PIDAlbumKey, "")
if err != nil {
return fmt.Errorf("getting album PID conf: %w", err)
}
// TODO Parallelize multiple job when we have multiple libraries
var total int64
var totalChanged int64
@@ -282,7 +277,7 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string
}
for filePath, info := range allInfo {
md := metadata.New(filePath, info)
track := md.ToMediaFile(entry.job.lib.ID, entry.id)
track := md.ToMediaFile(entry.job.lib, entry.id)
tracks = append(tracks, track)
for _, t := range track.Tags.FlattenAll() {
uniqueTags[t.ID] = t
@@ -293,7 +288,7 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string
if prev := toImport[filePath]; prev != nil {
prevAlbumID = prev.AlbumID
} else {
prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf)
prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf)
}
_, ok := entry.albumIDMap[track.AlbumID]
if prevAlbumID != track.AlbumID && !ok {
+35
View File
@@ -0,0 +1,35 @@
package scanner
import (
"context"
"path/filepath"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// stubFolderRepo satisfies just enough of FolderRepository for newScanJob to run
// without touching a real database.
type stubFolderRepo struct {
model.FolderRepository
}
func (stubFolderRepo) GetFolderUpdateInfo(model.Library, ...string) (map[string]model.FolderUpdateInfo, error) {
return map[string]model.FolderUpdateInfo{}, nil
}
var _ = Describe("newScanJob PID state", func() {
It("carries the library's previously scanned album spec onto the job", func() {
libPath, err := filepath.Abs(".")
Expect(err).ToNot(HaveOccurred())
ds := &tests.MockDataStore{MockedFolder: stubFolderRepo{}}
lib := model.Library{ID: 1, Name: "Test", Path: libPath, ScannedPIDAlbum: "old_spec"}
job, err := newScanJob(context.Background(), ds, lib, false, nil)
Expect(err).ToNot(HaveOccurred())
Expect(job.prevAlbumPIDConf).To(Equal("old_spec"))
})
})
+3 -9
View File
@@ -10,7 +10,6 @@ import (
"time"
ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
@@ -336,15 +335,10 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState)
log.Error(ctx, "Scanner: Error updating last scan completed", "lib", lib.Name, err)
return fmt.Errorf("updating last scan completed: %w", err)
}
err = tx.Property(ctx).Put(consts.PIDTrackKey, conf.Server.PID.Track)
err = tx.Library(ctx).UpdateScannedPIDs(lib.ID, lib.EffectivePIDAlbum(), lib.EffectivePIDTrack())
if err != nil {
log.Error(ctx, "Scanner: Error updating track PID conf", err)
return fmt.Errorf("updating track PID conf: %w", err)
}
err = tx.Property(ctx).Put(consts.PIDAlbumKey, conf.Server.PID.Album)
if err != nil {
log.Error(ctx, "Scanner: Error updating album PID conf", err)
return fmt.Errorf("updating album PID conf: %w", err)
log.Error(ctx, "Scanner: Error updating scanned PID specs", "lib", lib.Name, err)
return fmt.Errorf("updating scanned PID specs: %w", err)
}
if state.changesDetected.Load() {
log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name)
+5 -1
View File
@@ -22,7 +22,11 @@ func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.Inspec
return nil, model.ErrNotFound
}
return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID)
lib, err := ds.Library(ctx).Get(file.LibraryID)
if err != nil {
return nil, err
}
return core.Inspect(file.AbsolutePath(), *lib, file.FolderID)
}
func inspect(ds model.DataStore) http.HandlerFunc {
+12
View File
@@ -106,6 +106,18 @@ func (m *MockLibraryRepo) Put(library *model.Library, colsToUpdate ...string) er
return nil
}
func (m *MockLibraryRepo) UpdateScannedPIDs(id int, album, track string) error {
if m.Err != nil {
return m.Err
}
if lib, ok := m.Data[id]; ok {
lib.ScannedPIDAlbum = album
lib.ScannedPIDTrack = track
m.Data[id] = lib
}
return nil
}
func (m *MockLibraryRepo) Delete(id int) error {
if m.Err != nil {
return m.Err
+9 -3
View File
@@ -1,8 +1,14 @@
import React from 'react'
import Link from '@material-ui/core/Link'
import { docsUrl } from '../utils'
export const DocLink = ({ path, children }) => (
<a href={docsUrl(path)} target={'_blank'} rel="noopener noreferrer">
export const DocLink = ({ path, children, ...rest }) => (
<Link
href={docsUrl(path)}
target="_blank"
rel="noopener noreferrer"
{...rest}
>
{children}
</a>
</Link>
)
+1 -2
View File
@@ -1,7 +1,6 @@
export const REST_URL = '/api'
export const INSIGHTS_DOC_URL =
'https://navidrome.org/docs/getting-started/insights'
export const INSIGHTS_DOC_PATH = '/docs/getting-started/insights'
export const M3U_MIME_TYPE = 'audio/x-mpegurl'
+3 -2
View File
@@ -18,9 +18,10 @@ import { useGetOne, usePermissions, useTranslate, useNotify } from 'react-admin'
import { Tabs, Tab } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import config from '../config'
import { DocLink } from '../common'
import { DialogTitle } from './DialogTitle'
import { DialogContent } from './DialogContent'
import { INSIGHTS_DOC_URL } from '../consts.js'
import { INSIGHTS_DOC_PATH } from '../consts.js'
import subsonic from '../subsonic/index.js'
import { Typography } from '@material-ui/core'
import TableHead from '@material-ui/core/TableHead'
@@ -189,7 +190,7 @@ const AboutTabContent = ({
{translate(`about.links.lastInsightsCollection`)}:
</TableCell>
<TableCell align="left">
<Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link>
<DocLink path={INSIGHTS_DOC_PATH}>{insightsStatus}</DocLink>
</TableCell>
</TableRow>
) : null}
+18 -3
View File
@@ -304,11 +304,14 @@
"totalDuration": "Duration",
"defaultNewUsers": "Default for New Users",
"createdAt": "Created",
"updatedAt": "Updated"
"updatedAt": "Updated",
"pidAlbum": "Album Grouping",
"pidTrack": "Track Identity"
},
"sections": {
"basic": "Basic Information",
"statistics": "Statistics"
"statistics": "Statistics",
"persistentIds": "Persistent IDs"
},
"actions": {
"scan": "Scan Library",
@@ -333,12 +336,24 @@
"pathNotDirectory": "Library path must be a directory",
"pathNotFound": "Library path not found",
"pathNotAccessible": "Library path is not accessible",
"pathInvalid": "Invalid library path"
"pathInvalid": "Invalid library path",
"pidAlbumRecursive": "Album grouping cannot reference albumid",
"pidAlbumCustomRequired": "A custom album grouping spec is required"
},
"messages": {
"deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.",
"scanInProgress": "Scan in progress...",
"noLibrariesAssigned": "No libraries assigned to this user"
},
"pid": {
"default": "Default (from configuration file)",
"folder": "Folder-based",
"custom": "Custom…",
"customHelp": "A PID spec. See the Navidrome documentation for the syntax.",
"trackHelp": "Leave empty to use the value from the configuration file.",
"confirmTitle": "Trigger a full scan?",
"confirmContent": "Changing how tracks and albums are identified requires re-scanning every library from scratch. This can take a long time on a large collection. Continue?",
"docsLink": "Learn about Persistent IDs"
}
},
"plugin": {
+8 -10
View File
@@ -6,7 +6,6 @@ import Button from '@material-ui/core/Button'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CircularProgress from '@material-ui/core/CircularProgress'
import Link from '@material-ui/core/Link'
import TextField from '@material-ui/core/TextField'
import { ThemeProvider, makeStyles } from '@material-ui/core/styles'
import {
@@ -22,7 +21,8 @@ import Notification from './Notification'
import useCurrentTheme from '../themes/useCurrentTheme'
import config from '../config'
import { clearQueue } from '../actions'
import { INSIGHTS_DOC_URL } from '../consts.js'
import { INSIGHTS_DOC_PATH } from '../consts.js'
import { DocLink } from '../common'
const useStyles = makeStyles(
(theme) => ({
@@ -186,7 +186,7 @@ const FormLogin = ({ loading, handleSubmit, validate }) => {
)
}
const InsightsNotice = ({ url }) => {
const InsightsNotice = ({ path }) => {
const translate = useTranslate()
const classes = useStyles()
@@ -210,17 +210,15 @@ const InsightsNotice = ({ url }) => {
// Push the text before the bracket
segments.push(line.slice(lastIndex, match.index))
// Push the <Link> component
// Push the <DocLink> component
segments.push(
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
<DocLink
path={path}
key={`${lineIndex}-${match.index}`}
style={{ cursor: 'pointer' }}
>
{bracketText}
</Link>,
</DocLink>,
)
// Update lastIndex to the character right after the bracketed text
@@ -306,7 +304,7 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => {
{translate('ra.auth.buttonCreateAdmin')}
</Button>
</CardActions>
<InsightsNotice url={INSIGHTS_DOC_URL} />
<InsightsNotice path={INSIGHTS_DOC_PATH} />
</Card>
<Notification />
</div>
+25 -1
View File
@@ -10,7 +10,10 @@ import {
useNotify,
useRedirect,
} from 'react-admin'
import { Title } from '../common'
import { Typography, Box } from '@material-ui/core'
import { Title, DocLink } from '../common'
import PIDAlbumInput from './PIDAlbumInput'
import { parsePidField, PID_DOCS_PATH } from './pidUtils'
const LibraryCreate = (props) => {
const translate = useTranslate()
@@ -76,6 +79,27 @@ const LibraryCreate = (props) => {
<TextInput source="name" validate={[required()]} />
<TextInput source="path" validate={[required()]} fullWidth />
<BooleanInput source="defaultNewUsers" />
<Box mt="1em" />
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.persistentIds')}
</Typography>
<Typography variant="body2" gutterBottom>
<DocLink path={PID_DOCS_PATH}>
{translate('resources.library.pid.docsLink')}
</DocLink>
</Typography>
<PIDAlbumInput />
<TextInput
source="pidTrack"
label={translate('resources.library.fields.pidTrack')}
helperText={translate('resources.library.pid.trackHelp')}
parse={parsePidField}
fullWidth
/>
</SimpleForm>
</Create>
)
+237 -177
View File
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'
import React, { useCallback, useState } from 'react'
import {
Edit,
FormWithRedirect,
@@ -12,11 +12,15 @@ import {
useNotify,
useRedirect,
Toolbar,
Confirm,
} from 'react-admin'
import PropTypes from 'prop-types'
import { Typography, Box } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import DeleteLibraryButton from './DeleteLibraryButton'
import { Title } from '../common'
import PIDAlbumInput from './PIDAlbumInput'
import { pidChanged, parsePidField, PID_DOCS_PATH } from './pidUtils'
import { Title, DocLink } from '../common'
import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js'
const useStyles = makeStyles({
@@ -47,17 +51,16 @@ const CustomToolbar = ({ showDelete, ...props }) => (
</Toolbar>
)
const LibraryEdit = (props) => {
// Must be <Edit>'s direct child: that's the only element react-admin injects
// the real fetched record into (LibraryEdit's own props never get it).
const LibraryEditForm = ({ canDelete, canEditPath, record, ...formProps }) => {
const translate = useTranslate()
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
const [pendingValues, setPendingValues] = useState(null)
// Library ID 1 is protected (main library)
const canDelete = props.id !== '1'
const canEditPath = props.id !== '1'
const save = useCallback(
const doSave = useCallback(
async (values) => {
try {
await mutate(
@@ -81,191 +84,248 @@ const LibraryEdit = (props) => {
[mutate, notify, redirect],
)
// Changing the PID spec triggers a full scan server-side; confirm first.
const handleSave = useCallback(
(values) => {
if (pidChanged(record, values)) {
setPendingValues(values)
return undefined
}
return doSave(values)
},
[doSave, record],
)
return (
<Edit title={<LibraryTitle />} undoable={false} {...props}>
<FormWithRedirect
{...props}
save={save}
render={(formProps) => (
<form onSubmit={formProps.handleSubmit}>
<Box p="1em" maxWidth="800px">
<Box display="flex">
<Box flex={1} mr="1em">
{/* Basic Information */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.basic')}
</Typography>
<FormWithRedirect
{...formProps}
record={record}
save={handleSave}
render={(formRenderProps) => (
<form onSubmit={formRenderProps.handleSubmit}>
<Box p="1em" maxWidth="800px">
<Box display="flex">
<Box flex={1} mr="1em">
{/* Basic Information */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.basic')}
</Typography>
<TextInput
source="name"
label={translate('resources.library.fields.name')}
validate={[required()]}
variant="outlined"
/>
<TextInput
source="path"
label={translate('resources.library.fields.path')}
validate={[required()]}
fullWidth
variant="outlined"
InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1
/>
<BooleanInput
source="defaultNewUsers"
label={translate(
'resources.library.fields.defaultNewUsers',
)}
variant="outlined"
/>
<TextInput
source="name"
label={translate('resources.library.fields.name')}
validate={[required()]}
variant="outlined"
/>
<TextInput
source="path"
label={translate('resources.library.fields.path')}
validate={[required()]}
fullWidth
variant="outlined"
InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1
/>
<BooleanInput
source="defaultNewUsers"
label={translate('resources.library.fields.defaultNewUsers')}
variant="outlined"
/>
<Box mt="2em" />
<Box mt="2em" />
{/* Statistics - Two Column Layout */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.statistics')}
</Typography>
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.persistentIds')}
</Typography>
<Typography variant="body2" gutterBottom>
<DocLink path={PID_DOCS_PATH}>
{translate('resources.library.pid.docsLink')}
</DocLink>
</Typography>
<Box display="flex">
<Box flex={1} mr="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalSongs'}
label={translate('resources.library.fields.totalSongs')}
fullWidth
variant="outlined"
/>
</Box>
<Box flex={1} ml="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalAlbums'}
label={translate(
'resources.library.fields.totalAlbums',
)}
fullWidth
variant="outlined"
/>
</Box>
</Box>
<PIDAlbumInput />
<Box display="flex">
<Box flex={1} mr="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalArtists'}
label={translate(
'resources.library.fields.totalArtists',
)}
fullWidth
variant="outlined"
/>
</Box>
<Box flex={1} ml="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalSize'}
label={translate('resources.library.fields.totalSize')}
format={(v) => formatBytes(v, 2)}
fullWidth
variant="outlined"
/>
</Box>
</Box>
<TextInput
source="pidTrack"
label={translate('resources.library.fields.pidTrack')}
helperText={translate('resources.library.pid.trackHelp')}
parse={parsePidField}
fullWidth
variant="outlined"
/>
<Box display="flex">
<Box flex={1} mr="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalDuration'}
label={translate(
'resources.library.fields.totalDuration',
)}
format={formatDuration2}
fullWidth
variant="outlined"
/>
</Box>
<Box flex={1} ml="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalMissingFiles'}
label={translate(
'resources.library.fields.totalMissingFiles',
)}
fullWidth
variant="outlined"
/>
</Box>
</Box>
<Box mt="2em" />
{/* Timestamps Section */}
<Box mb="1em">
<Typography
variant="body2"
color="textSecondary"
gutterBottom
>
{translate('resources.library.fields.lastScanAt')}
</Typography>
<DateField
variant="body1"
source="lastScanAt"
showTime
record={formProps.record}
{/* Statistics - Two Column Layout */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.statistics')}
</Typography>
<Box display="flex">
<Box flex={1} mr="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalSongs'}
label={translate('resources.library.fields.totalSongs')}
fullWidth
variant="outlined"
/>
</Box>
<Box mb="1em">
<Typography
variant="body2"
color="textSecondary"
gutterBottom
>
{translate('resources.library.fields.updatedAt')}
</Typography>
<DateField
variant="body1"
source="updatedAt"
showTime
record={formProps.record}
/>
</Box>
<Box mb="2em">
<Typography
variant="body2"
color="textSecondary"
gutterBottom
>
{translate('resources.library.fields.createdAt')}
</Typography>
<DateField
variant="body1"
source="createdAt"
showTime
record={formProps.record}
<Box flex={1} ml="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalAlbums'}
label={translate('resources.library.fields.totalAlbums')}
fullWidth
variant="outlined"
/>
</Box>
</Box>
<Box display="flex">
<Box flex={1} mr="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalArtists'}
label={translate('resources.library.fields.totalArtists')}
fullWidth
variant="outlined"
/>
</Box>
<Box flex={1} ml="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalSize'}
label={translate('resources.library.fields.totalSize')}
format={(v) => formatBytes(v, 2)}
fullWidth
variant="outlined"
/>
</Box>
</Box>
<Box display="flex">
<Box flex={1} mr="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalDuration'}
label={translate(
'resources.library.fields.totalDuration',
)}
format={formatDuration2}
fullWidth
variant="outlined"
/>
</Box>
<Box flex={1} ml="0.5em">
<TextInput
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalMissingFiles'}
label={translate(
'resources.library.fields.totalMissingFiles',
)}
fullWidth
variant="outlined"
/>
</Box>
</Box>
{/* Timestamps Section */}
<Box mb="1em">
<Typography
variant="body2"
color="textSecondary"
gutterBottom
>
{translate('resources.library.fields.lastScanAt')}
</Typography>
<DateField
variant="body1"
source="lastScanAt"
showTime
record={formRenderProps.record}
/>
</Box>
<Box mb="1em">
<Typography
variant="body2"
color="textSecondary"
gutterBottom
>
{translate('resources.library.fields.updatedAt')}
</Typography>
<DateField
variant="body1"
source="updatedAt"
showTime
record={formRenderProps.record}
/>
</Box>
<Box mb="2em">
<Typography
variant="body2"
color="textSecondary"
gutterBottom
>
{translate('resources.library.fields.createdAt')}
</Typography>
<DateField
variant="body1"
source="createdAt"
showTime
record={formRenderProps.record}
/>
</Box>
</Box>
</Box>
</Box>
<CustomToolbar
handleSubmitWithRedirect={formProps.handleSubmitWithRedirect}
pristine={formProps.pristine}
saving={formProps.saving}
record={formProps.record}
showDelete={canDelete}
/>
</form>
)}
/>
<CustomToolbar
handleSubmitWithRedirect={formRenderProps.handleSubmitWithRedirect}
pristine={formRenderProps.pristine}
saving={formRenderProps.saving}
record={formRenderProps.record}
showDelete={canDelete}
/>
<Confirm
isOpen={pendingValues !== null}
title={translate('resources.library.pid.confirmTitle')}
content={translate('resources.library.pid.confirmContent')}
onConfirm={() => {
const values = pendingValues
setPendingValues(null)
doSave(values)
}}
onClose={() => setPendingValues(null)}
/>
</form>
)}
/>
)
}
LibraryEditForm.propTypes = {
canDelete: PropTypes.bool,
canEditPath: PropTypes.bool,
record: PropTypes.object,
}
const LibraryEdit = (props) => {
// Library ID 1 is protected (main library)
const canDelete = props.id !== '1'
const canEditPath = props.id !== '1'
return (
<Edit title={<LibraryTitle />} undoable={false} {...props}>
<LibraryEditForm canDelete={canDelete} canEditPath={canEditPath} />
</Edit>
)
}
+156
View File
@@ -0,0 +1,156 @@
import * as React from 'react'
import { act, render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import LibraryEdit from './LibraryEdit'
const hooks = vi.hoisted(() => ({
record: null,
mutate: vi.fn(),
notify: vi.fn(),
redirect: vi.fn(),
save: null,
confirmOnConfirm: null,
confirmOnClose: null,
}))
// `Edit` mirrors ra-ui-materialui's EditView: the fetched record is injected
// into the direct child only, never into the props of whoever renders <Edit>.
vi.mock('react-admin', () => ({
Edit: ({ children }) =>
React.cloneElement(React.Children.only(children), {
record: hooks.record,
}),
FormWithRedirect: ({ record, save, render: renderForm }) => {
hooks.save = save
return renderForm({
record,
handleSubmit: () => {},
pristine: false,
saving: false,
})
},
TextInput: ({ source }) => <input readOnly data-testid={`input-${source}`} />,
BooleanInput: ({ source }) => (
<input type="checkbox" readOnly data-testid={`input-${source}`} />
),
required: () => () => null,
SaveButton: () => <button data-testid="save-button">Save</button>,
DateField: ({ source }) => <div data-testid={`date-${source}`} />,
useTranslate: () => (key) => key,
useMutation: () => [hooks.mutate],
useNotify: () => hooks.notify,
useRedirect: () => hooks.redirect,
Toolbar: ({ children }) => <div data-testid="toolbar">{children}</div>,
Confirm: ({ isOpen, onConfirm, onClose }) => {
hooks.confirmOnConfirm = onConfirm
hooks.confirmOnClose = onClose
return isOpen ? <div data-testid="confirm-dialog" /> : null
},
}))
vi.mock('./PIDAlbumInput', () => ({
__esModule: true,
default: () => <div data-testid="pid-album-input" />,
}))
vi.mock('./DeleteLibraryButton', () => ({
__esModule: true,
default: () => <button data-testid="delete-library-button">Delete</button>,
}))
vi.mock('../common', () => ({
Title: () => <div data-testid="title" />,
DocLink: ({ children }) => <a href="#doc">{children}</a>,
}))
vi.mock('@material-ui/core/styles', () => ({
makeStyles: () => () => ({}),
}))
vi.mock('@material-ui/core', () => ({
Typography: ({ children }) => <p>{children}</p>,
Box: ({ children }) => <div>{children}</div>,
}))
describe('LibraryEdit save guard', () => {
beforeEach(() => {
vi.clearAllMocks()
hooks.save = null
hooks.confirmOnConfirm = null
hooks.confirmOnClose = null
})
it('saves directly when the PID spec did not change', async () => {
hooks.record = { id: '1', name: 'Library 1', pidAlbum: 'folder' }
render(<LibraryEdit id="1" />)
await act(async () => {
hooks.save({ id: '1', name: 'Library 1 renamed', pidAlbum: 'folder' })
})
expect(hooks.mutate).toHaveBeenCalled()
expect(screen.queryByTestId('confirm-dialog')).toBeNull()
})
it('opens the confirm dialog when pidAlbum changes from empty to folder', async () => {
hooks.record = { id: '1', name: 'Library 1', pidAlbum: '' }
render(<LibraryEdit id="1" />)
await act(async () => {
hooks.save({ id: '1', name: 'Library 1', pidAlbum: 'folder' })
})
expect(hooks.mutate).not.toHaveBeenCalled()
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
it('opens the confirm dialog when pidAlbum reverts from folder to empty', async () => {
hooks.record = { id: '1', name: 'Library 1', pidAlbum: 'folder' }
render(<LibraryEdit id="1" />)
await act(async () => {
hooks.save({ id: '1', name: 'Library 1', pidAlbum: '' })
})
expect(hooks.mutate).not.toHaveBeenCalled()
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
it('does not save on cancel, and leaves the form usable for another save', async () => {
hooks.record = { id: '1', name: 'Library 1', pidAlbum: '' }
render(<LibraryEdit id="1" />)
await act(async () => {
hooks.save({ id: '1', name: 'Library 1', pidAlbum: 'folder' })
})
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
await act(async () => {
hooks.confirmOnClose()
})
expect(hooks.mutate).not.toHaveBeenCalled()
expect(screen.queryByTestId('confirm-dialog')).toBeNull()
await act(async () => {
hooks.save({ id: '1', name: 'Library 1', pidAlbum: '' })
})
expect(hooks.mutate).toHaveBeenCalled()
})
it('calls the mutation when the dialog is confirmed', async () => {
hooks.record = { id: '1', name: 'Library 1', pidAlbum: '' }
render(<LibraryEdit id="1" />)
await act(async () => {
hooks.save({ id: '1', name: 'Library 1', pidAlbum: 'folder' })
})
await act(async () => {
hooks.confirmOnConfirm()
})
expect(hooks.mutate).toHaveBeenCalled()
expect(screen.queryByTestId('confirm-dialog')).toBeNull()
})
})
+68
View File
@@ -0,0 +1,68 @@
import React, { useState } from 'react'
import { TextInput, required, useTranslate } from 'react-admin'
import { useField } from 'react-final-form'
import { MenuItem, TextField } from '@material-ui/core'
import {
PID_CUSTOM,
PID_DEFAULT,
PID_FOLDER,
pidAlbumMode,
parsePidField,
} from './pidUtils'
const PIDAlbumInput = () => {
const translate = useTranslate()
// Keeps pidAlbum registered in every mode, otherwise final-form's pristine
// flag never clears when Default/Folder is chosen (no field is registered).
const { input } = useField('pidAlbum', { parse: parsePidField })
const [mode, setMode] = useState(() => pidAlbumMode(input.value))
const handleChange = (event) => {
const next = event.target.value
setMode(next)
input.onChange(next === PID_FOLDER ? PID_FOLDER : '')
}
return (
<>
<TextField
select
fullWidth
variant="outlined"
margin="dense"
value={mode}
onChange={handleChange}
label={translate('resources.library.fields.pidAlbum')}
SelectProps={{
SelectDisplayProps: { 'data-testid': 'pidAlbum-mode-select' },
}}
>
<MenuItem value={PID_DEFAULT}>
{translate('resources.library.pid.default')}
</MenuItem>
<MenuItem value={PID_FOLDER}>
{translate('resources.library.pid.folder')}
</MenuItem>
<MenuItem value={PID_CUSTOM}>
{translate('resources.library.pid.custom')}
</MenuItem>
</TextField>
{mode === PID_CUSTOM && (
<TextInput
source="pidAlbum"
label={translate('resources.library.fields.pidAlbum')}
helperText={translate('resources.library.pid.customHelp')}
validate={[
required('resources.library.validation.pidAlbumCustomRequired'),
]}
parse={parsePidField}
data-testid="pidAlbum-custom-input"
fullWidth
variant="outlined"
/>
)}
</>
)
}
export default PIDAlbumInput
+145
View File
@@ -0,0 +1,145 @@
import React from 'react'
import {
render,
screen,
fireEvent,
within,
waitFor,
} from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Form, FormSpy } from 'react-final-form'
import { TestContext } from 'ra-test'
import PIDAlbumInput from './PIDAlbumInput'
const renderWithForm = (initialValues, onSubmit = () => {}) =>
render(
<TestContext>
<Form
onSubmit={onSubmit}
initialValues={initialValues}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<PIDAlbumInput />
<button type="submit">save</button>
</form>
)}
/>
</TestContext>,
)
const renderWithPristineTracking = (initialValues) => {
const pristineHistory = []
render(
<TestContext>
<Form
onSubmit={() => {}}
initialValues={initialValues}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<PIDAlbumInput />
<FormSpy
subscription={{ pristine: true }}
onChange={({ pristine }) => pristineHistory.push(pristine)}
/>
</form>
)}
/>
</TestContext>,
)
return pristineHistory
}
describe('PIDAlbumInput', () => {
it('hides the custom text box when the value is empty', () => {
renderWithForm({ pidAlbum: '' })
expect(screen.queryByTestId('pidAlbum-custom-input')).toBeNull()
})
it('hides the custom text box when the value is folder', () => {
renderWithForm({ pidAlbum: 'folder' })
expect(screen.queryByTestId('pidAlbum-custom-input')).toBeNull()
})
it('shows the custom text box for an unrecognized value', () => {
renderWithForm({ pidAlbum: 'musicbrainz_albumid' })
expect(screen.getByTestId('pidAlbum-custom-input')).toBeInTheDocument()
expect(screen.getByDisplayValue('musicbrainz_albumid')).toBeInTheDocument()
})
it('blocks submit when the custom value is emptied', () => {
const onSubmit = vi.fn()
renderWithForm({ pidAlbum: 'musicbrainz_albumid' }, onSubmit)
const input = screen
.getByTestId('pidAlbum-custom-input')
.querySelector('input')
fireEvent.change(input, { target: { value: '' } })
fireEvent.click(screen.getByText('save'))
expect(onSubmit).not.toHaveBeenCalled()
expect(
screen.getByText('resources.library.validation.pidAlbumCustomRequired'),
).toBeInTheDocument()
})
it('clears form pristine when switching to Folder-based mode', async () => {
const pristineHistory = renderWithPristineTracking({ pidAlbum: '' })
expect(pristineHistory[pristineHistory.length - 1]).toBe(true)
fireEvent.mouseDown(screen.getByTestId('pidAlbum-mode-select'))
fireEvent.click(
within(screen.getByRole('listbox')).getByText(
'resources.library.pid.folder',
),
)
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(false),
)
})
it('returns to pristine when the mode is changed back to the original value', async () => {
const pristineHistory = renderWithPristineTracking({ pidAlbum: '' })
fireEvent.mouseDown(screen.getByTestId('pidAlbum-mode-select'))
fireEvent.click(
within(screen.getByRole('listbox')).getByText(
'resources.library.pid.folder',
),
)
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(false),
)
fireEvent.mouseDown(screen.getByTestId('pidAlbum-mode-select'))
fireEvent.click(
within(screen.getByRole('listbox')).getByText(
'resources.library.pid.default',
),
)
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(true),
)
})
it('returns to pristine when a custom value is reverted back to the original value', async () => {
const pristineHistory = renderWithPristineTracking({
pidAlbum: 'musicbrainz_albumid',
})
const input = screen
.getByTestId('pidAlbum-custom-input')
.querySelector('input')
fireEvent.change(input, { target: { value: 'other_tag' } })
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(false),
)
fireEvent.change(input, { target: { value: 'musicbrainz_albumid' } })
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(true),
)
})
})
+48
View File
@@ -0,0 +1,48 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Form, FormSpy } from 'react-final-form'
import { TestContext } from 'ra-test'
import { TextInput } from 'react-admin'
import { parsePidField } from './pidUtils'
// Mirrors the pidTrack TextInput used in LibraryEdit.jsx / LibraryCreate.jsx.
const renderPidTrackField = (initialValues) => {
const pristineHistory = []
render(
<TestContext>
<Form
onSubmit={() => {}}
initialValues={initialValues}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<TextInput source="pidTrack" parse={parsePidField} />
<FormSpy
subscription={{ pristine: true }}
onChange={({ pristine }) => pristineHistory.push(pristine)}
/>
</form>
)}
/>
</TestContext>,
)
return pristineHistory
}
describe('pidTrack field pristine behavior', () => {
it('returns to pristine when the value is reverted back to the original empty value', async () => {
const pristineHistory = renderPidTrackField({ pidTrack: '' })
expect(pristineHistory[pristineHistory.length - 1]).toBe(true)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'title' } })
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(false),
)
fireEvent.change(input, { target: { value: '' } })
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(true),
)
})
})
+19
View File
@@ -0,0 +1,19 @@
export const PID_DEFAULT = ''
export const PID_FOLDER = 'folder'
export const PID_CUSTOM = '__custom__'
export const pidAlbumMode = (value) => {
if (!value) return PID_DEFAULT
if (value === PID_FOLDER) return PID_FOLDER
return PID_CUSTOM
}
export const pidChanged = (record = {}, values = {}) =>
(record.pidAlbum || '') !== (values.pidAlbum || '') ||
(record.pidTrack || '') !== (values.pidTrack || '')
// react-final-form's default parse turns '' into undefined, which deletes the
// key from values and leaves the field permanently dirty against a '' initialValue.
export const parsePidField = (value) => value ?? ''
export const PID_DOCS_PATH = '/docs/usage/configuration/persistent-ids/'
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { pidChanged } from './pidUtils'
describe('pidChanged', () => {
it('is false when nothing changed', () => {
expect(pidChanged({ pidAlbum: 'folder' }, { pidAlbum: 'folder' })).toBe(
false,
)
})
it('is false when both sides are empty in different ways', () => {
expect(pidChanged({}, { pidAlbum: '', pidTrack: undefined })).toBe(false)
})
it('is true when the album spec changed', () => {
expect(pidChanged({ pidAlbum: '' }, { pidAlbum: 'folder' })).toBe(true)
})
it('is true when the track spec changed', () => {
expect(pidChanged({ pidTrack: '' }, { pidTrack: 'title' })).toBe(true)
})
it('ignores unrelated fields', () => {
expect(pidChanged({ name: 'a' }, { name: 'b' })).toBe(false)
})
})