mirror of
https://github.com/navidrome/navidrome.git
synced 2026-09-11 04:57:35 -04:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97d6d29fd | ||
|
|
1c5a8d747c | ||
|
|
5907594f7d | ||
|
|
769939a657 | ||
|
|
84b6fb2239 | ||
|
|
95ec20d137 | ||
|
|
19e40f7265 | ||
|
|
e8858b3451 | ||
|
|
64831b233e | ||
|
|
c8a59f38b9 | ||
|
|
04a0e63078 | ||
|
|
dace5448b2 | ||
|
|
40f9c8a025 | ||
|
|
94db8b3f44 | ||
|
|
4ba073a8bc | ||
|
|
8049e10bca | ||
|
|
5f348e4503 | ||
|
|
83bb16f193 |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{} }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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/'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user