Compare 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
62 changed files with 1482 additions and 1117 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
}
+6 -11
View File
@@ -27,12 +27,11 @@ type TranscodeOptions struct {
Command string // DB command template (used to detect custom vs default)
Format string // Target format (mp3, opus, aac, flac)
FilePath string
BitRate int // kbps, 0 = codec default
SampleRate int // 0 = no constraint
Channels int // 0 = no constraint
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
Offset int // seconds
Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header.
BitRate int // kbps, 0 = codec default
SampleRate int // 0 = no constraint
Channels int // 0 = no constraint
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
Offset int // seconds
}
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
@@ -87,11 +86,7 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
} else {
args = buildTemplateArgs(opts)
}
out, err := e.start(ctx, args)
if err != nil {
return nil, err
}
return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil
return e.start(ctx, args)
}
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
-35
View File
@@ -3,7 +3,6 @@ package ffmpeg
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
@@ -685,40 +684,6 @@ var _ = Describe("ffmpeg", func() {
})
Expect(err).To(MatchError(context.Canceled))
})
It("fills in total_samples on a piped FLAC transcode", func() {
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "tests/fixtures/test.flac",
Duration: 1, // the fixture is exactly 1s at 44100Hz
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
out, err := io.ReadAll(stream)
Expect(err).ToNot(HaveOccurred())
Expect(string(out[:4])).To(Equal("fLaC"))
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
})
It("patches the duration net of the requested offset", func() {
// The command has no %t, so ffmpeg still emits the whole fixture.
// What is under test is the header arithmetic, not the audio.
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "tests/fixtures/test.flac",
Duration: 3,
Offset: 1,
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
out, err := io.ReadAll(stream)
Expect(err).ToNot(HaveOccurred())
Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100)))
})
})
Context("stderr capture", func() {
-66
View File
@@ -1,66 +0,0 @@
package ffmpeg
import (
"bytes"
"encoding/binary"
"errors"
"io"
"math"
)
const (
flacPrefixLen = 26 // through the last total_samples byte
flacMaxTotalSamples = 1<<36 - 1
)
// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0
// when writing to a pipe, since a decoder cannot seek a cached FLAC without it.
func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser {
if duration <= 0 {
return r
}
return &flacPatcher{ReadCloser: r, duration: duration}
}
type flacPatcher struct {
io.ReadCloser
duration float32
// Peeking here rather than in the constructor keeps Transcode from blocking
// until ffmpeg has emitted its first bytes.
stream io.Reader
}
func (f *flacPatcher) Read(p []byte) (int, error) {
if f.stream == nil {
prefix := make([]byte, flacPrefixLen)
n, err := io.ReadFull(f.ReadCloser, prefix)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return 0, err
}
prefix = prefix[:n]
if err == nil {
setFLACTotalSamples(prefix, f.duration)
}
f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser)
}
return f.stream.Read(p)
}
// setFLACTotalSamples takes the rate from the header rather than the transcode
// options, so a resampled (-ar) output still gets the right count.
func setFLACTotalSamples(prefix []byte, duration float32) {
if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 {
return
}
// 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples
info := binary.BigEndian.Uint64(prefix[18:])
rate := info >> 44
if rate == 0 || info&flacMaxTotalSamples != 0 {
return
}
total := math.Round(float64(duration) * float64(rate))
if total > flacMaxTotalSamples {
return
}
binary.BigEndian.PutUint64(prefix[18:], info|uint64(total))
}
-142
View File
@@ -1,142 +0,0 @@
package ffmpeg
import (
"bytes"
"errors"
"io"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Decoded independently so the specs do not mirror the production bit-twiddling.
func readSampleRate(b []byte) int {
return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4
}
func readTotalSamples(b []byte) uint64 {
return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25])
}
var _ = Describe("patchFLACDuration", func() {
var fileFLAC []byte
// Zeroing total_samples reproduces what a piped transcode emits.
pipedFLAC := func() []byte {
b := bytes.Clone(fileFLAC)
b[21] &= 0xF0
clear(b[22:26])
return b
}
readAll := func(in []byte, duration float32) []byte {
out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration))
Expect(err).ToNot(HaveOccurred())
return out
}
BeforeEach(func() {
var err error
fileFLAC, err = os.ReadFile("tests/fixtures/test.flac")
Expect(err).ToNot(HaveOccurred())
Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate
})
It("fills in total_samples from the duration", func() {
out := readAll(pipedFLAC(), 1.0)
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
})
It("takes the sample rate from the header, not from the source file", func() {
in := pipedFLAC()
// Rewrite the header's rate to 48000, as -ar would.
in[18], in[19] = 0x0B, 0xB8
in[20] &= 0x0F
out := readAll(in, 2.0)
Expect(readSampleRate(out)).To(Equal(48000))
Expect(readTotalSamples(out)).To(Equal(uint64(96000)))
})
It("rounds to the nearest sample rather than truncating", func() {
// float32(0.7)*44100 is 30869.9995, so truncation would lose a sample.
out := readAll(pipedFLAC(), 0.7)
Expect(readTotalSamples(out)).To(Equal(uint64(30870)))
})
It("passes through when the duration overflows the 36-bit field", func() {
in := pipedFLAC()
Expect(readAll(in, 2e6)).To(Equal(in))
})
It("leaves everything after the header untouched", func() {
in := pipedFLAC()
out := readAll(in, 1.0)
Expect(out).To(HaveLen(len(in)))
Expect(out[26:]).To(Equal(in[26:]))
Expect(out[:18]).To(Equal(in[:18]))
})
It("leaves an already-populated total_samples alone", func() {
out := readAll(fileFLAC, 99.0)
Expect(out).To(Equal(fileFLAC))
})
It("passes through a stream that is not FLAC", func() {
in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes")
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through when the first metadata block is not STREAMINFO", func() {
in := pipedFLAC()
in[4] = 0x04 // VORBIS_COMMENT
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through a stream shorter than the STREAMINFO fields it patches", func() {
in := pipedFLAC()[:20]
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through an empty stream", func() {
Expect(readAll(nil, 1.0)).To(BeEmpty())
})
It("passes through when the duration is zero or negative", func() {
in := pipedFLAC()
Expect(readAll(in, 0)).To(Equal(in))
Expect(readAll(in, -5)).To(Equal(in))
})
It("passes through when the header declares no sample rate", func() {
in := pipedFLAC()
in[18], in[19] = 0, 0
in[20] &= 0x0F
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("propagates a read error from the underlying stream", func() {
_, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader(
bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0))
Expect(err).To(MatchError("boom"))
})
It("closes the underlying stream", func() {
c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())}
Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed())
Expect(c.closed).To(BeTrue())
})
})
type errReader struct{}
func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") }
type closeSpy struct {
io.Reader
closed bool
}
func (c *closeSpy) Close() error { c.closed = true; return nil }
+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
-76
View File
@@ -1144,82 +1144,6 @@ var _ = Describe("Decider", func() {
})
})
Context("Player-forced format", func() {
symfonium := func() *ClientInfo {
return &ClientInfo{
Name: "Symfonium",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}},
},
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
}
It("direct plays a flac source forced to flac", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := symfonium()
Expect(ci.ForceFormat("flac")).To(BeTrue())
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("still transcodes a 24-bit flac when the client caps bit depth", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := symfonium()
ci.CodecProfiles = []CodecProfile{{
Type: CodecProfileTypeAudio, Name: "flac",
Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}},
}}
Expect(ci.ForceFormat("flac")).To(BeTrue())
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
})
It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
ci := symfonium()
Expect(ci.ForceFormat("mp3")).To(BeTrue())
ci.CapBitrate(192)
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetBitrate).To(Equal(192))
})
It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
ci := symfonium()
Expect(ci.ForceFormat("mp3")).To(BeTrue())
ci.CapBitrate(192)
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("transcodes a flac source forced to mp3", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := symfonium()
Expect(ci.ForceFormat("mp3")).To(BeTrue())
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
})
})
})
Describe("ensureProbed", func() {
-1
View File
@@ -268,7 +268,6 @@ func NewTranscodingCache() TranscodingCache {
BitDepth: job.bitDepth,
Channels: job.channels,
Offset: job.offset,
Duration: job.mf.Duration,
})
if err != nil {
release()
+8 -18
View File
@@ -59,38 +59,28 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool {
return changed
}
// ForceFormat narrows the client to transcoding to targetFormat, but only if the
// client already declares a profile for it. All matching profiles are kept so
// negotiation can still pick among them (e.g. by protocol). Direct play is rebuilt
// from those profiles rather than dropped, since declaring a transcoding profile
// for a format is proof the client can play it. Returns false when unsupported.
// ForceFormat narrows the client to transcoding to targetFormat and suppresses
// direct play, but only if the client already declares a profile for that
// format. All matching profiles are kept so negotiation can still pick among
// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or
// unsupported.
func (ci *ClientInfo) ForceFormat(targetFormat string) bool {
if targetFormat == "" {
return false
}
var matched []Profile
var directPlay []DirectPlayProfile
for i := range ci.TranscodingProfiles {
p := &ci.TranscodingProfiles[i]
// matchesContainer is alias-aware, so a forced "oga" (legacy Opus
// target_format) still matches a resolved "opus" profile.
container, format := resolveTargetFormat(p)
if !matchesContainer(format, []string{targetFormat}) {
continue
if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) {
matched = append(matched, ci.TranscodingProfiles[i])
}
matched = append(matched, *p)
directPlay = append(directPlay, DirectPlayProfile{
Containers: []string{container},
AudioCodecs: []string{format},
Protocols: []string{ProtocolHTTP},
MaxAudioChannels: p.MaxAudioChannels,
})
}
if len(matched) == 0 {
return false
}
ci.TranscodingProfiles = matched
ci.DirectPlayProfiles = directPlay
ci.DirectPlayProfiles = nil
return true
}
+2 -30
View File
@@ -58,7 +58,7 @@ var _ = Describe("ClientInfo", func() {
})
Describe("ForceFormat", func() {
It("restricts direct play to the forced format when supported", func() {
It("restricts to the forced format and clears direct play when supported", func() {
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
TranscodingProfiles: []Profile{
@@ -71,35 +71,7 @@ var _ = Describe("ClientInfo", func() {
Expect(ok).To(BeTrue())
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{
Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP},
}))
})
It("keeps direct play for a source already in the forced format", func() {
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
ok := ci.ForceFormat("flac")
Expect(ok).To(BeTrue())
Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{
Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{ProtocolHTTP},
}))
})
It("carries the channel limit of the forced profile into direct play", func() {
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP, MaxAudioChannels: 2},
},
}
Expect(ci.ForceFormat("flac")).To(BeTrue())
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].MaxAudioChannels).To(Equal(2))
Expect(ci.DirectPlayProfiles).To(BeEmpty())
})
It("matches a container-only forced format (mp3)", func() {
@@ -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
}
+17 -17
View File
@@ -3,11 +3,11 @@ module github.com/navidrome/navidrome
go 1.27
// Fork to implement raw tags support
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df
require (
github.com/Masterminds/squirrel v1.5.4
github.com/andybalholm/cascadia v1.3.5
github.com/andybalholm/cascadia v1.3.4
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
@@ -26,7 +26,7 @@ require (
github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-viper/encoding/ini v0.1.1
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gohugoio/hashstructure v1.1.0
github.com/gohugoio/hashstructure v1.0.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
@@ -35,16 +35,16 @@ require (
github.com/jellydator/ttlcache/v3 v3.4.1
github.com/kardianos/service v1.3.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.3.0
github.com/mattn/go-sqlite3 v1.14.52
github.com/lestrrat-go/jwx/v3 v3.2.0
github.com/mattn/go-sqlite3 v1.14.50
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.32.2
github.com/onsi/ginkgo/v2 v2.32.1
github.com/onsi/gomega v1.43.0
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pmezard/go-difflib v1.0.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.28.0
github.com/pressly/goose/v3 v3.27.3
github.com/prometheus/client_golang v1.24.1
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
@@ -60,13 +60,13 @@ require (
github.com/zeebo/xxh3 v1.1.0
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
golang.org/x/time v0.16.0
golang.org/x/image v0.45.0
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/text v0.41.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -114,7 +114,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
@@ -131,8 +131,8 @@ require (
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
+44 -44
View File
@@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8=
github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec h1:3VyOFsbsRtCQqdq/+fcmD3D6zlRvKSG7RCixgrdWfEo=
github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df h1:LdLQVAWVc6hCzqnrfVIEXOhP+r0iSit+EvsXwZDyL70=
github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
@@ -94,8 +94,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4=
github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8=
github.com/gohugoio/hashstructure v1.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg=
github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -134,8 +134,8 @@ github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFd
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -159,16 +159,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4=
github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8=
github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA=
github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@@ -185,8 +185,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg=
github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
@@ -199,16 +199,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk=
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
@@ -304,34 +304,34 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
@@ -350,11 +350,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+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)
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
"golang.org/x/sync/singleflight"
"github.com/navidrome/navidrome/conf"
@@ -77,9 +78,9 @@ func (api *Router) routes() http.Handler {
inner.Post("/system/ping", api.ping)
inner.Get("/quickconnect/enabled", api.quickConnectEnabled)
// Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated
// brute-force surface, so it must share the same per-client throttle when one is configured.
// brute-force surface, so it must share the same per-IP throttle when one is configured.
if conf.Server.AuthRequestLimit > 0 {
limiter := server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName)
} else {
inner.Post("/users/authenticatebyname", api.authenticateByName)
-23
View File
@@ -6,7 +6,6 @@ import (
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
@@ -85,26 +84,4 @@ var _ = Describe("Router", func() {
Expect(login()).To(Equal(http.StatusUnauthorized))
Expect(login()).To(Equal(http.StatusTooManyRequests))
})
It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 1
conf.Server.AuthWindowLength = time.Minute
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Every request arrives on the same proxy connection, so only the resolved client IP
// can separate the buckets.
handler := middleware.ClientIPFromHeader("X-Real-IP")(api)
login := func(clientIP string) int {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`))
r.RemoteAddr = "10.0.0.1:1234"
r.Header.Set("X-Real-IP", clientIP)
handler.ServeHTTP(w, r)
return w.Code
}
Expect(login("203.0.113.1")).To(Equal(http.StatusUnauthorized))
Expect(login("203.0.113.1")).To(Equal(http.StatusTooManyRequests))
Expect(login("203.0.113.2")).To(Equal(http.StatusUnauthorized))
})
})
+1 -1
View File
@@ -136,7 +136,7 @@ func isSameMachine(r *http.Request, remote netip.Addr) bool {
return parseIP(local.String()) == remote
}
// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP.
// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP.
func remoteIP(r *http.Request) netip.Addr {
return parseIP(r.RemoteAddr)
}
+11 -71
View File
@@ -7,9 +7,7 @@ import (
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
@@ -17,7 +15,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@@ -168,77 +165,20 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
})
}
// realIPMiddleware resolves the request's client IP into the context, where it can be read with
// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration.
// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that
// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept
// in the context as request.ReverseProxyIp.
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy.
func realIPMiddleware(next http.Handler) http.Handler {
trusted := conf.Server.ExtAuth.TrustedSources
fromPeer := middleware.ClientIPFromRemoteAddr(next)
if trusted == "" {
return fromPeer
if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP,
).Handler(next)
}
// Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP,
// X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list.
fromProxy := chi.Chain(
middleware.ClientIPFromRemoteAddr,
middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...),
middleware.ClientIPFromHeader("X-Real-IP"),
middleware.ClientIPFromHeader("True-Client-IP"),
).Handler(mirrorClientIP(next))
dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if validateIPAgainstList(r.RemoteAddr, trusted) {
fromProxy.ServeHTTP(w, r)
return
}
log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr)
fromPeer.ServeHTTP(w, r)
})
return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch)
}
// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer.
func mirrorClientIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) {
r.RemoteAddr = ip
}
next.ServeHTTP(w, r)
})
}
// peerHost returns the host part of RemoteAddr, which may already be a bare IP.
func peerHost(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR
// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust
// exactly one hop.
func trustedProxyPrefixes(list string) []string {
var prefixes []string
for _, entry := range strings.Split(list, ",") {
entry = strings.TrimSpace(entry)
if _, err := netip.ParsePrefix(entry); err == nil {
prefixes = append(prefixes, entry)
}
}
return prefixes
}
// ClientIPRateLimiter returns a rate limiter keyed by the client IP resolved by realIPMiddleware,
// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer
// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket.
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil
})
// The middleware is applied without a trusted reverse proxy to support other use-cases such as multiple clients
// behind a caching proxy. In this case, navidrome only uses the request's RemoteAddr for logging, so the security
// impact of reading the headers from untrusted sources is limited.
return middleware.RealIP(next)
}
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
-97
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -436,100 +435,4 @@ var _ = Describe("middlewares", func() {
})
})
})
Describe("realIPMiddleware", func() {
var resolved, remoteAddr string
var proxyIP any
next := func(w http.ResponseWriter, r *http.Request) {
resolved = middleware.GetClientIP(r.Context())
remoteAddr = r.RemoteAddr
proxyIP = r.Context().Value(request.ReverseProxyIp)
}
call := func(peer string, headers map[string]string) {
resolved, remoteAddr, proxyIP = "", "", nil
r := httptest.NewRequest("POST", "/auth/login", nil)
r.RemoteAddr = peer
for k, v := range headers {
r.Header.Set(k, v)
}
realIPMiddleware(http.HandlerFunc(next)).ServeHTTP(httptest.NewRecorder(), r)
}
Context("without a trusted proxy", func() {
It("ignores client-supplied forwarding headers", func() {
call("10.0.0.1:1234", map[string]string{
"X-Forwarded-For": "203.0.113.5",
"X-Real-IP": "203.0.113.6",
"True-Client-IP": "203.0.113.7",
})
Expect(resolved).To(Equal("10.0.0.1"))
})
It("leaves RemoteAddr untouched", func() {
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
Expect(remoteAddr).To(Equal("10.0.0.1:1234"))
})
})
Context("with a trusted proxy", func() {
BeforeEach(func() {
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
})
It("uses the forwarded client IP when the peer is a trusted proxy", func() {
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.1"})
Expect(resolved).To(Equal("203.0.113.5"))
Expect(remoteAddr).To(Equal("203.0.113.5"))
})
It("honours X-Real-IP from a trusted proxy", func() {
call("10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.6"})
Expect(resolved).To(Equal("203.0.113.6"))
})
It("ignores forwarding headers when the peer is not a trusted proxy", func() {
call("198.51.100.9:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
Expect(resolved).To(Equal("198.51.100.9"))
Expect(remoteAddr).To(Equal("198.51.100.9:1234"))
})
It("keeps the peer address in the context for external auth", func() {
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
Expect(proxyIP).To(Equal("10.0.0.1:1234"))
})
})
})
Describe("ClientIPRateLimiter", func() {
var handler http.Handler
JustBeforeEach(func() {
handler = realIPMiddleware(ClientIPRateLimiter(2, time.Minute)(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })))
})
attempt := func(peer string, header, value string) int {
r := httptest.NewRequest("POST", "/auth/login", nil)
r.RemoteAddr = peer
r.Header.Set(header, value)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w.Code
}
DescribeTable("keeps one bucket per peer when the forwarding header is rotated",
func(header string) {
Expect(attempt("198.51.100.9:1", header, "203.0.113.1")).To(Equal(http.StatusOK))
Expect(attempt("198.51.100.9:2", header, "203.0.113.2")).To(Equal(http.StatusOK))
Expect(attempt("198.51.100.9:3", header, "203.0.113.3")).To(Equal(http.StatusTooManyRequests))
},
Entry("X-Forwarded-For", "X-Forwarded-For"),
Entry("X-Real-IP", "X-Real-IP"),
Entry("True-Client-IP", "True-Client-IP"),
)
Context("behind a trusted proxy", func() {
BeforeEach(func() {
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
})
It("gives each real client its own bucket", func() {
Expect(attempt("10.0.0.1:1", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK))
Expect(attempt("10.0.0.1:2", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK))
Expect(attempt("10.0.0.1:3", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusTooManyRequests))
Expect(attempt("10.0.0.1:4", "X-Forwarded-For", "203.0.113.2")).To(Equal(http.StatusOK))
})
})
})
})
+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 {
+2 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
@@ -208,7 +209,7 @@ func (s *Server) mountAuthenticationRoutes() chi.Router {
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
"windowLength", conf.Server.AuthWindowLength)
rateLimiter := ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
r.With(rateLimiter).Post("/login", login(s.ds))
} else {
log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks")
@@ -205,76 +205,3 @@ var _ = Describe("Sharing Cross-User Isolation", Ordered, func() {
Expect(check.Shares.Share[0].ID).To(Equal(shareID))
})
})
var _ = Describe("Sharing Downloadable Default", func() {
var albumID string
BeforeEach(func() {
conf.Server.EnableSharing = true
setupTestDB()
conf.Server.EnableDownloads = true
albumID = albumIDByName("Abbey Road")
})
createShare := func(params ...string) *model.Share {
GinkgoHelper()
resp := doReq("createShare", append([]string{"id", albumID}, params...)...)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Shares.Share).To(HaveLen(1))
share, err := ds.Share(ctx).Get(resp.Shares.Share[0].ID)
Expect(err).ToNot(HaveOccurred())
return share
}
DescribeTable("createShare resolves downloadable",
func(defaultDownloadable, enableDownloads bool, params []string, expected bool) {
conf.Server.DefaultDownloadableShare = defaultDownloadable
conf.Server.EnableDownloads = enableDownloads
Expect(createShare(params...).Downloadable).To(Equal(expected))
},
Entry("applies the default when the param is absent", true, true, nil, true),
Entry("stays off when the default is off", false, true, nil, false),
Entry("ignores the default when downloads are disabled", true, false, nil, false),
Entry("honors an explicit false over the default", true, true, []string{"downloadable", "false"}, false),
Entry("honors an explicit true over the default", false, true, []string{"downloadable", "true"}, true),
)
It("updateShare keeps the current downloadable when the param is absent", func() {
conf.Server.DefaultDownloadableShare = true
share := createShare()
Expect(share.Downloadable).To(BeTrue())
resp := doReq("updateShare", "id", share.ID, "description", "Updated")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Description).To(Equal("Updated"))
Expect(updated.Downloadable).To(BeTrue())
})
It("updateShare applies an explicit downloadable and keeps the description", func() {
conf.Server.DefaultDownloadableShare = true
share := createShare("description", "Keep me")
resp := doReq("updateShare", "id", share.ID, "downloadable", "false")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Downloadable).To(BeFalse())
Expect(updated.Description).To(Equal("Keep me"))
})
It("updateShare clears the description when it is sent empty", func() {
share := createShare("description", "Clear me")
resp := doReq("updateShare", "id", share.ID, "description", "")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Description).To(BeEmpty())
})
})
+7 -25
View File
@@ -1,13 +1,11 @@
package subsonic
import (
"cmp"
"net/http"
"strings"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic/responses"
@@ -62,10 +60,9 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) {
description, _ := p.String("description")
repo := api.share.NewRepository(r.Context())
share := &model.Share{
Description: description,
Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads),
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
Description: description,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
}
id, err := repo.(rest.Persistable).Save(share)
@@ -90,27 +87,12 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) {
return nil, err
}
description, _ := p.String("description")
repo := api.share.NewRepository(r.Context())
// The update always writes description and downloadable, so read back the
// stored value for whichever one the client omitted.
description := p.StringPtr("description")
downloadable := p.BoolPtr("downloadable")
if description == nil || downloadable == nil {
current, err := repo.Read(id)
if err != nil {
return nil, err
}
cur := current.(*model.Share)
description = cmp.Or(description, &cur.Description)
downloadable = cmp.Or(downloadable, &cur.Downloadable)
}
share := &model.Share{
ID: id,
Description: *description,
Downloadable: *downloadable,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ID: id,
Description: description,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
}
err = repo.(rest.Persistable).Update(id, share)
+9 -16
View File
@@ -280,19 +280,12 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
return stream.IsAACCodec(p.Container)
})
player, hasPlayer := request.PlayerFrom(ctx)
// Honor the player's forced transcoding format, falling back to normal
// negotiation when the client can't play it (issue #5583).
maxBitRate := 0
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
if clientInfo.ForceFormat(trc.TargetFormat) {
// DirectPlayProfile carries no bitrate, so this ceiling is the only
// thing keeping an over-bitrate source out of direct play.
maxBitRate = trc.DefaultBitRate
} else {
if !clientInfo.ForceFormat(trc.TargetFormat) {
clientName := clientInfo.Name
if hasPlayer && player.Client != "" {
if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" {
clientName = player.Client
}
log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation",
@@ -300,13 +293,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
}
}
// The player's own MaxBitRate outranks the forced-format default (issue #5583).
if hasPlayer && player.MaxBitRate > 0 {
maxBitRate = player.MaxBitRate
}
if clientInfo.CapBitrate(maxBitRate) {
log.Debug(ctx, "Applied bitrate ceiling to transcode decision",
"maxBitRate", maxBitRate, "client", clientInfo.Name)
// Apply the player's MaxBitRate as a ceiling on the client's declared
// limits (issue #5583). Both fields are capped because the client sends
// them independently here; capping only MaxAudioBitrate would let an
// independent MaxTranscodingAudioBitrate slip through computeBitrate.
if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) {
log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision",
"playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
}
// Get media file
+2 -43
View File
@@ -369,7 +369,7 @@ var _ = Describe("Transcode endpoints", func() {
mockTD.token = "token"
})
It("forces a supported format and narrows direct play to it", func() {
It("forces a supported format and clears direct play", func() {
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
"transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"},
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
@@ -380,11 +380,7 @@ var _ = Describe("Transcode endpoints", func() {
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{
Containers: []string{"ogg"},
AudioCodecs: []string{"opus"},
Protocols: []string{"http"},
}))
Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty())
})
It("falls back to negotiation when the forced format is unsupported", func() {
@@ -420,43 +416,6 @@ var _ = Describe("Transcode endpoints", func() {
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128))
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128))
})
withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request {
ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate})
ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate})
return r.WithContext(ctx)
}
It("applies the transcoding default bitrate when the player sets no maxBitRate", func() {
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192))
})
It("prefers the player maxBitRate over the transcoding default bitrate", func() {
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
})
It("ignores the transcoding default bitrate when the forced format is unsupported", func() {
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero())
})
})
})
+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 -8
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'
@@ -31,9 +30,3 @@ export const DEFAULT_SHARE_BITRATE = 128
export const BITRATE_CHOICES = [
32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320,
].map((b) => ({ id: b, name: b.toString() }))
// 0 is a valid stored value ("no default bit rate") that BITRATE_CHOICES cannot express.
export const TRANSCODING_BITRATE_CHOICES = [
{ id: 0, name: 'resources.transcoding.choices.noDefaultBitRate' },
...BITRATE_CHOICES,
]
+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 -6
View File
@@ -200,9 +200,6 @@
"targetFormat": "Target Format",
"defaultBitRate": "Default Bit Rate",
"command": "Command"
},
"choices": {
"noDefaultBitRate": "None"
}
},
"playlist": {
@@ -307,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",
@@ -336,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)
})
})
+1 -5
View File
@@ -59,11 +59,7 @@ const useCurrentTheme = () => {
return useMemo(
() => ({
...theme,
props: {
...theme.props,
MuiUseMediaQuery: { noSsr: true },
MuiPopover: { disableScrollLock: true },
},
props: { ...theme.props, MuiUseMediaQuery: { noSsr: true } },
}),
[theme],
)
+2 -2
View File
@@ -8,7 +8,7 @@ import {
useTranslate,
} from 'react-admin'
import { Title } from '../common'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
import { BITRATE_CHOICES } from '../consts'
const TranscodingTitle = () => {
const translate = useTranslate()
@@ -28,7 +28,7 @@ const TranscodingCreate = (props) => (
<TextInput source="targetFormat" validate={[required()]} />
<SelectInput
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
choices={BITRATE_CHOICES}
defaultValue={192}
/>
<TextInput
+2 -5
View File
@@ -9,7 +9,7 @@ import {
} from 'react-admin'
import { Title } from '../common'
import { TranscodingNote } from './TranscodingNote'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
import { BITRATE_CHOICES } from '../consts'
const TranscodingTitle = ({ record }) => {
const translate = useTranslate()
@@ -28,10 +28,7 @@ const TranscodingEdit = (props) => {
<SimpleForm variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<TextInput source="targetFormat" validate={[required()]} />
<SelectInput
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<SelectInput source="defaultBitRate" choices={BITRATE_CHOICES} />
<TextInput source="command" fullWidth validate={[required()]} />
</SimpleForm>
</Edit>
+3 -13
View File
@@ -1,8 +1,7 @@
import React from 'react'
import { Datagrid, SelectField, TextField } from 'react-admin'
import { Datagrid, TextField } from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { SimpleList, List } from '../common'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
import config from '../config'
const TranscodingList = (props) => {
@@ -17,22 +16,13 @@ const TranscodingList = (props) => {
<SimpleList
primaryText={(r) => r.name}
secondaryText={(r) => `format: ${r.targetFormat}`}
tertiaryText={(r) => (
<SelectField
record={r}
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
)}
tertiaryText={(r) => r.defaultBitRate}
/>
) : (
<Datagrid rowClick={config.enableTranscodingConfig ? 'edit' : 'show'}>
<TextField source="name" />
<TextField source="targetFormat" />
<SelectField
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextField source="defaultBitRate" />
<TextField source="command" />
</Datagrid>
)}
+2 -6
View File
@@ -1,8 +1,7 @@
import React from 'react'
import { SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'
import { Show, SimpleShowLayout, TextField } from 'react-admin'
import { Title } from '../common'
import { TranscodingNote } from './TranscodingNote'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = ({ record }) => {
return <Title subTitle={`Transcoding ${record ? record.name : ''}`} />
@@ -17,10 +16,7 @@ const TranscodingShow = (props) => {
<SimpleShowLayout>
<TextField source="name" />
<TextField source="targetFormat" />
<SelectField
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextField source="defaultBitRate" />
<TextField source="command" />
</SimpleShowLayout>
</Show>