Compare commits

..
Author SHA1 Message Date
YorkandDeluan Quintão 2aa7a5c466 fix(ui): prevent Safari album grid resize when top menus open (#6125)
* fix(ui): prevent Safari album grid resize when top menus open

* fix(ui): disable scroll lock for all popovers

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-10 20:48:33 -04:00
Deluan Quintão 4067e36a06 Merge pull request #6126 from navidrome/t3code/update-go-dependencies
chore(deps): update direct Go dependencies and taglib fork
2026-09-10 15:08:21 -04:00
Deluan 964d3c778b build(deps): update direct Go dependencies and taglib fork
Bumps all 13 direct dependencies that had newer releases, plus the
go-taglib fork pin. No source changes were needed.

The jwx bump to v3.3.0 carries a security fix (GHSA-4cf7-xm37-g63h):
custom claim, header and JWK names were written unescaped, so a name
containing a quote could inject extra members. Navidrome is not
affected - every claim name we emit is a hardcoded literal - but the
fix is worth taking. cascadia v1.3.5 similarly limits selector nesting
to avoid a stack overflow, and our only selector is a constant.

go-sqlite3 v1.14.52 is the only bump with real behavior change: it
flushes the statement cache on schema changes, steps cached statements
eagerly, and drops the per-row goroutine used for query cancellation.
goose v3.28.0 raises its minimum to Go 1.26 and otherwise only touches
MySQL, ClickHouse and Azure SQL, which we do not use. The golang.org/x
bumps are routine. govulncheck reports no reachable vulnerabilities.

The taglib fork pin picks up two fixes. Audio properties are now
clamped with std::max(0, ...) before the unsigned conversion, so a
malformed file no longer reports a duration of ~49 days; this ports
upstream sentriz/go-taglib 0524e91 and additionally covers
bitsPerSample, which is specific to this fork. Bit depth is also now
reported for DSDIFF, TrueAudio and Shorten, which previously returned
0. Both values reach media_file only on re-extraction, so existing
libraries need a full scan to pick them up.
2026-09-10 15:00:01 -04:00
Deluan Quintão c14b598a01 Merge pull request #6124 from navidrome/fix/login-rate-limit-ip-spoofing
fix(server): key the login rate limit on a trust-aware client IP
2026-09-10 13:29:02 -04:00
Deluan Quintão 25e7b5b20d Merge branch 'master' into fix/login-rate-limit-ip-spoofing 2026-09-10 13:28:00 -04:00
Deluan Quintão bb386b13bf Merge pull request #6105 from navidrome/fix-forceformat-directplay
fix(transcoding): don't re-encode a source already in the forced format, and make piped FLAC seekable
2026-09-10 13:27:08 -04:00
Deluan Quintão 08eb46c8ad Merge branch 'master' into fix-forceformat-directplay 2026-09-10 13:26:45 -04:00
Deluan 055fbde3cf fix(server): key the login rate limit on a trust-aware client IP
The RealIP middleware rewrote RemoteAddr from the True-Client-IP, X-Real-IP
and X-Forwarded-For headers on every request, including when no trusted
reverse proxy was configured. The login rate limiters on /auth/login and the
Jellyfin /Users/AuthenticateByName derived their bucket from that value, so
an unauthenticated client could rotate a forwarding header and get a fresh
bucket for every password attempt, defeating the brute-force protection.

Resolve the client IP with chi's ClientIPFrom* middlewares instead. The
forwarding headers are only honoured when ExtAuth.TrustedSources is set and
the connecting peer is in that list, reusing the trust check that external
authentication already applies; otherwise the peer address is used. The
X-Forwarded-For chain is now walked against the trusted CIDRs rather than
taking its leftmost entry, so a spoofed value prepended by the client is
skipped.

Both limiters now key on the resolved address. The resolved address is still
mirrored into RemoteAddr, so request logging, player registration and the
Jellyfin local-network check keep reporting the client rather than the proxy.

Reported by gehan-psbc.
2026-09-10 08:59:01 -04:00
Deluan Quintão 72975a95fb fix(subsonic): honor DefaultDownloadableShare in createShare (#6121)
* fix(subsonic): honor DefaultDownloadableShare in createShare

The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.

createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes #6119.

updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.

* refactor(subsonic): trim the share downloadable lookup and align with the UI

updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.

createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.

The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.

* fix(subsonic): keep the share description on a downloadable-only update

updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.

This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.

Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
2026-09-09 20:29:00 -04:00
Deluan Quintão e7b449b805 Merge branch 'master' into fix-forceformat-directplay 2026-09-09 17:38:56 -04:00
Deluan 8d77a49b31 fix(ui): allow setting a transcoding Default Bit Rate of 0
The Default Bit Rate dropdown on the Transcoding create/edit forms was fed
BITRATE_CHOICES, which starts at 32. There was no way to pick 0, and the
SelectInput was not resettable, so an admin could neither create nor restore a
transcoding with no default bit rate, such as the default FLAC one (seeded with
0 in consts.DefaultTranscodings). Editing that row also rendered a blank
dropdown, since its stored value matched no choice.

Adds TRANSCODING_BITRATE_CHOICES, which prepends a 0 entry labelled 'None' to
the shared list. The forms use it as SelectInput choices, and the list and
read-only show view render it through SelectField, so all four screens resolve
the label from the same array and cannot drift. The shared BITRATE_CHOICES is
left untouched, because 0 is not a meaningful option for the player Max. Bit
Rate or the share dialog.

Reported in discussion #6107, where a user had deleted the default
transcodings and could not recreate the FLAC one.
2026-09-09 17:35:03 -04:00
Deluan 89026012ab fix(transcoding): make piped FLAC transcodes seekable
The FLAC muxer writes STREAMINFO before it knows the stream length, then
rewinds at the end to fill total_samples in. Navidrome pipes ffmpeg's stdout
(-f flac -), which is not seekable, so ffmpeg logs "unable to rewrite FLAC
header" and the field stays 0. A decoder needs total_samples to turn a
timestamp into a byte offset, so it reports an unknown duration and refuses to
seek. Online playback hides this because the client re-requests with a new
offset each time, but an offline copy is permanently unseekable, the symptom
reported against Symfonium where seeking a downloaded track jumps back to the
start.

Transcode now wraps its own output and rewrites total_samples as the first
bytes flow past. This lives in core/ffmpeg because the unseekable pipe is that
package's doing: buildDynamicArgs is what appends the trailing '-'. core/stream
only learns a target format and hands back an io.ReadCloser, so compensating
there leaked a transcoder implementation detail one layer up. TranscodeOptions
grows a Duration field alongside the existing Offset, which also puts the
duration-minus-offset arithmetic in the same function that emits -ss.

The wrapper runs on every transcode rather than only FLAC targets: the format
on a transcoding row is a declared target that nothing validates against the
command's actual -f, so a custom command can emit FLAC under any target_format.
The magic-byte check inside the wrapper is the authoritative test and costs a
26-byte peek. The output sample rate is read back out of the header ffmpeg just
wrote rather than taken from the transcode options, so a resampled (-ar) output
still gets the right count. Anything that is not a FLAC stream with an unset
total_samples passes through byte for byte.

Measured on a 177s source: before, total_samples=0 and ffprobe reported
duration N/A; after, total_samples=7807023 and duration 177.03s, with the audio
payload byte-identical. This affects every piped FLAC regardless of the source
format; only FLAC stores an authoritative "unknown", which is why mp3, opus
and aac survive the same pipe.

No SEEKTABLE is synthesised and the MD5 is left zero: both are optional, and
decoders binary-search using total_samples alone.
2026-09-07 16:47:42 -04:00
Deluan 404837799b fix(subsonic): don't re-encode a source already in the player's forced format
When a player has a forced transcoding format, ClientInfo.ForceFormat cleared
DirectPlayProfiles unconditionally. A FLAC source on a player configured to
transcode to FLAC was therefore re-encoded to FLAC, wasting CPU and bandwidth
for no gain. Worse, the transcoder pipes ffmpeg output to stdout, so the
resulting FLAC has total_samples=0 and no seek table -- an offline copy of it
can never be seeked. Reported against getTranscodeDecision by the Symfonium
author.

ForceFormat now rebuilds DirectPlayProfiles from the matching transcoding
profiles instead of dropping them: a client declaring a transcoding profile for
a format is proof it can consume that format, so a source already in it is
served as-is. Container and codec come from resolveTargetFormat, so a legacy
"oga" target_format yields an ogg/opus profile, and the profile's
MaxAudioChannels is carried across.

DirectPlayProfile has no bitrate field, so restoring direct play needs a
ceiling to keep an over-bitrate source out of it. GetTranscodeDecision now
seeds that ceiling from the transcoding row's DefaultBitRate when a format was
successfully forced, with the player's own MaxBitRate still taking precedence.
This also closes a gap where the new endpoint ignored DefaultBitRate entirely:
an mp3 320 source on a player forced to mp3@192 was served at 320, while the
legacy /rest/stream path correctly gave 192.

Applied via CapBitrate, which only ever lowers, so a client declaring a
stricter limit keeps it. The legacy path (applyServerOverride) is untouched --
ForceFormat has no other callers.
2026-09-07 14:56:41 -04:00
62 changed files with 1123 additions and 1488 deletions

No files matched your search

+1 -1
View File
@@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
return new(metadata.ToMediaFile(1, "folderID"))
}
BeforeEach(func() {
+1 -1
View File
@@ -66,7 +66,7 @@ func runInspector(args []string) {
log.Warn("Not an audio file", "file", filePath)
continue
}
output, err := core.Inspect(filePath, model.Library{ID: 1}, "")
output, err := core.Inspect(filePath, 1, "")
if err != nil {
log.Warn("Unable to process file", "file", filePath, "error", err)
continue
+14 -1
View File
@@ -15,6 +15,7 @@ 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"
@@ -181,6 +182,18 @@ 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 {
@@ -193,7 +206,7 @@ func runInitialScan(ctx context.Context) func() error {
if err != nil {
return err
}
pidHasChanged, err := scanner.PIDConfChanged(ctx, ds)
pidHasChanged, err := pidHashChanged(ds)
if err != nil {
return err
}
+11 -6
View File
@@ -27,11 +27,12 @@ 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
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.
}
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
@@ -86,7 +87,11 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
} else {
args = buildTemplateArgs(opts)
}
return e.start(ctx, args)
out, err := e.start(ctx, args)
if err != nil {
return nil, err
}
return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil
}
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
+35
View File
@@ -3,6 +3,7 @@ package ffmpeg
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
@@ -684,6 +685,40 @@ var _ = Describe("ffmpeg", func() {
})
Expect(err).To(MatchError(context.Canceled))
})
It("fills in total_samples on a piped FLAC transcode", func() {
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "tests/fixtures/test.flac",
Duration: 1, // the fixture is exactly 1s at 44100Hz
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
out, err := io.ReadAll(stream)
Expect(err).ToNot(HaveOccurred())
Expect(string(out[:4])).To(Equal("fLaC"))
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
})
It("patches the duration net of the requested offset", func() {
// The command has no %t, so ffmpeg still emits the whole fixture.
// What is under test is the header arithmetic, not the audio.
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "tests/fixtures/test.flac",
Duration: 3,
Offset: 1,
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
out, err := io.ReadAll(stream)
Expect(err).ToNot(HaveOccurred())
Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100)))
})
})
Context("stderr capture", func() {
+66
View File
@@ -0,0 +1,66 @@
package ffmpeg
import (
"bytes"
"encoding/binary"
"errors"
"io"
"math"
)
const (
flacPrefixLen = 26 // through the last total_samples byte
flacMaxTotalSamples = 1<<36 - 1
)
// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0
// when writing to a pipe, since a decoder cannot seek a cached FLAC without it.
func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser {
if duration <= 0 {
return r
}
return &flacPatcher{ReadCloser: r, duration: duration}
}
type flacPatcher struct {
io.ReadCloser
duration float32
// Peeking here rather than in the constructor keeps Transcode from blocking
// until ffmpeg has emitted its first bytes.
stream io.Reader
}
func (f *flacPatcher) Read(p []byte) (int, error) {
if f.stream == nil {
prefix := make([]byte, flacPrefixLen)
n, err := io.ReadFull(f.ReadCloser, prefix)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return 0, err
}
prefix = prefix[:n]
if err == nil {
setFLACTotalSamples(prefix, f.duration)
}
f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser)
}
return f.stream.Read(p)
}
// setFLACTotalSamples takes the rate from the header rather than the transcode
// options, so a resampled (-ar) output still gets the right count.
func setFLACTotalSamples(prefix []byte, duration float32) {
if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 {
return
}
// 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples
info := binary.BigEndian.Uint64(prefix[18:])
rate := info >> 44
if rate == 0 || info&flacMaxTotalSamples != 0 {
return
}
total := math.Round(float64(duration) * float64(rate))
if total > flacMaxTotalSamples {
return
}
binary.BigEndian.PutUint64(prefix[18:], info|uint64(total))
}
+142
View File
@@ -0,0 +1,142 @@
package ffmpeg
import (
"bytes"
"errors"
"io"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Decoded independently so the specs do not mirror the production bit-twiddling.
func readSampleRate(b []byte) int {
return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4
}
func readTotalSamples(b []byte) uint64 {
return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25])
}
var _ = Describe("patchFLACDuration", func() {
var fileFLAC []byte
// Zeroing total_samples reproduces what a piped transcode emits.
pipedFLAC := func() []byte {
b := bytes.Clone(fileFLAC)
b[21] &= 0xF0
clear(b[22:26])
return b
}
readAll := func(in []byte, duration float32) []byte {
out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration))
Expect(err).ToNot(HaveOccurred())
return out
}
BeforeEach(func() {
var err error
fileFLAC, err = os.ReadFile("tests/fixtures/test.flac")
Expect(err).ToNot(HaveOccurred())
Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate
})
It("fills in total_samples from the duration", func() {
out := readAll(pipedFLAC(), 1.0)
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
})
It("takes the sample rate from the header, not from the source file", func() {
in := pipedFLAC()
// Rewrite the header's rate to 48000, as -ar would.
in[18], in[19] = 0x0B, 0xB8
in[20] &= 0x0F
out := readAll(in, 2.0)
Expect(readSampleRate(out)).To(Equal(48000))
Expect(readTotalSamples(out)).To(Equal(uint64(96000)))
})
It("rounds to the nearest sample rather than truncating", func() {
// float32(0.7)*44100 is 30869.9995, so truncation would lose a sample.
out := readAll(pipedFLAC(), 0.7)
Expect(readTotalSamples(out)).To(Equal(uint64(30870)))
})
It("passes through when the duration overflows the 36-bit field", func() {
in := pipedFLAC()
Expect(readAll(in, 2e6)).To(Equal(in))
})
It("leaves everything after the header untouched", func() {
in := pipedFLAC()
out := readAll(in, 1.0)
Expect(out).To(HaveLen(len(in)))
Expect(out[26:]).To(Equal(in[26:]))
Expect(out[:18]).To(Equal(in[:18]))
})
It("leaves an already-populated total_samples alone", func() {
out := readAll(fileFLAC, 99.0)
Expect(out).To(Equal(fileFLAC))
})
It("passes through a stream that is not FLAC", func() {
in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes")
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through when the first metadata block is not STREAMINFO", func() {
in := pipedFLAC()
in[4] = 0x04 // VORBIS_COMMENT
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through a stream shorter than the STREAMINFO fields it patches", func() {
in := pipedFLAC()[:20]
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through an empty stream", func() {
Expect(readAll(nil, 1.0)).To(BeEmpty())
})
It("passes through when the duration is zero or negative", func() {
in := pipedFLAC()
Expect(readAll(in, 0)).To(Equal(in))
Expect(readAll(in, -5)).To(Equal(in))
})
It("passes through when the header declares no sample rate", func() {
in := pipedFLAC()
in[18], in[19] = 0, 0
in[20] &= 0x0F
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("propagates a read error from the underlying stream", func() {
_, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader(
bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0))
Expect(err).To(MatchError("boom"))
})
It("closes the underlying stream", func() {
c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())}
Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed())
Expect(c.closed).To(BeTrue())
})
})
type errReader struct{}
func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") }
type closeSpy struct {
io.Reader
closed bool
}
func (c *closeSpy) Close() error { c.closed = true; return nil }
+2 -2
View File
@@ -15,7 +15,7 @@ type InspectOutput struct {
MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
}
func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) {
func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) {
path, file := filepath.Split(filePath)
s, err := storage.For(path)
@@ -43,7 +43,7 @@ func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutpu
result := &InspectOutput{
File: filePath,
RawTags: tags[file].Tags,
MappedTags: new(md.ToMediaFile(lib, folderId)),
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
}
return result, nil
+14 -41
View File
@@ -7,7 +7,6 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
@@ -179,7 +178,7 @@ func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
}
if r.scanner != nil {
go r.triggerScan(lib, "new", false)
go r.triggerScan(lib, "new")
}
// Send library refresh event to all clients
@@ -210,33 +209,24 @@ 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)
}
// 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)
// 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)
}
}
}
if (pathChanged || pidChanged) && r.scanner != nil {
go r.triggerScan(lib, "updated", pidChanged)
if r.scanner != nil {
go r.triggerScan(lib, "updated")
}
}
// Send library refresh event to all clients
@@ -280,7 +270,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
}
if r.scanner != nil {
go r.triggerScan(lib, "deleted", false)
go r.triggerScan(lib, "deleted")
}
// Send library refresh event to all clients
@@ -343,10 +333,6 @@ 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}
}
@@ -354,19 +340,6 @@ 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) {
@@ -434,10 +407,10 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
return nil
}
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)
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)
start := time.Now()
warnings, err := r.scanner.ScanAll(r.ctx, fullScan)
warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library
if err != nil {
log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
} else {
-150
View File
@@ -10,9 +10,7 @@ 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"
@@ -839,154 +837,6 @@ var _ = Describe("Library Service", func() {
})
})
Describe("PID changes", func() {
var repo rest.Persistable
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
})
It("rejects an album spec that references albumid", func() {
library := &model.Library{Name: "Test", Path: tempDir, PIDAlbum: "albumid,title"}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors).To(HaveKey("pidAlbum"))
})
It("accepts folder as an album spec", func() {
library := &model.Library{Name: "Test", Path: tempDir, PIDAlbum: "folder"}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
})
It("accepts the default album PID spec", func() {
library := &model.Library{Name: "Test", Path: tempDir, PIDAlbum: consts.DefaultAlbumPID}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
})
It("rejects a bare albumid spec", func() {
library := &model.Library{Name: "Test", Path: tempDir, PIDAlbum: "albumid"}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors).To(HaveKey("pidAlbum"))
})
It("accepts a spec where albumid is only a substring of another token", func() {
library := &model.Library{Name: "Test", Path: tempDir, PIDAlbum: "musicbrainz_albumid|album"}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
})
It("triggers a full scan when the album PID changes", func() {
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
library := &model.Library{ID: 1, Name: "Original Library", Path: tempDir, PIDAlbum: "folder"}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
calls := scanner.GetScanAllCalls()
Expect(calls[0].FullScan).To(BeTrue())
})
It("triggers a quick scan when only the path changes", func() {
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
newTempDir, err := os.MkdirTemp("", "navidrome-library-pid-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(newTempDir) })
library := &model.Library{ID: 1, Name: "Original Library", Path: newTempDir}
err = repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
calls := scanner.GetScanAllCalls()
Expect(calls[0].FullScan).To(BeFalse())
})
It("does not trigger a scan when the album PID override matches the effective default", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = consts.DefaultAlbumPID
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
library := &model.Library{ID: 1, Name: "Original Library", Path: tempDir, PIDAlbum: conf.Server.PID.Album}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
// No scan is spawned at all in this case, so there's no goroutine race to await:
// Consistently just confirms the count stays put over a short window.
Consistently(func() int {
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
It("triggers a full scan when clearing an override actually changes the effective spec", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "album_legacy"
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir, PIDAlbum: "folder"},
})
library := &model.Library{ID: 1, Name: "Original Library", Path: tempDir, PIDAlbum: ""}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
calls := scanner.GetScanAllCalls()
Expect(calls[0].FullScan).To(BeTrue())
})
It("does not treat pidAlbum as changed when a partial update omits it", func() {
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir, PIDAlbum: "folder"},
})
// Simulates a PUT body containing only "name": the decoded entity has a
// zero-valued PIDAlbum, but Put() below won't touch that column either.
library := &model.Library{ID: 1, Name: "Renamed Library", Path: tempDir}
err := repo.Update("1", library, "name")
Expect(err).NotTo(HaveOccurred())
Consistently(func() int {
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
})
Describe("Event Broadcasting", func() {
var repo rest.Persistable
+76
View File
@@ -1144,6 +1144,82 @@ var _ = Describe("Decider", func() {
})
})
Context("Player-forced format", func() {
symfonium := func() *ClientInfo {
return &ClientInfo{
Name: "Symfonium",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}},
},
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
}
It("direct plays a flac source forced to flac", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := symfonium()
Expect(ci.ForceFormat("flac")).To(BeTrue())
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("still transcodes a 24-bit flac when the client caps bit depth", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := symfonium()
ci.CodecProfiles = []CodecProfile{{
Type: CodecProfileTypeAudio, Name: "flac",
Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}},
}}
Expect(ci.ForceFormat("flac")).To(BeTrue())
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
})
It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
ci := symfonium()
Expect(ci.ForceFormat("mp3")).To(BeTrue())
ci.CapBitrate(192)
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetBitrate).To(Equal(192))
})
It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
ci := symfonium()
Expect(ci.ForceFormat("mp3")).To(BeTrue())
ci.CapBitrate(192)
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("transcodes a flac source forced to mp3", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := symfonium()
Expect(ci.ForceFormat("mp3")).To(BeTrue())
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
})
})
})
Describe("ensureProbed", func() {
+1
View File
@@ -268,6 +268,7 @@ func NewTranscodingCache() TranscodingCache {
BitDepth: job.bitDepth,
Channels: job.channels,
Offset: job.offset,
Duration: job.mf.Duration,
})
if err != nil {
release()
+18 -8
View File
@@ -59,28 +59,38 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool {
return changed
}
// 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.
// 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.
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.
if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) {
matched = append(matched, ci.TranscodingProfiles[i])
container, format := resolveTargetFormat(p)
if !matchesContainer(format, []string{targetFormat}) {
continue
}
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 = nil
ci.DirectPlayProfiles = directPlay
return true
}
+30 -2
View File
@@ -58,7 +58,7 @@ var _ = Describe("ClientInfo", func() {
})
Describe("ForceFormat", func() {
It("restricts to the forced format and clears direct play when supported", func() {
It("restricts direct play to the forced format when supported", func() {
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
TranscodingProfiles: []Profile{
@@ -71,7 +71,35 @@ 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(BeEmpty())
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))
})
It("matches a container-only forced format (mp3)", func() {
@@ -1,49 +0,0 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/consts"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddPerLibraryPid, downAddPerLibraryPid)
}
func upAddPerLibraryPid(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
ALTER TABLE library ADD COLUMN pid_album TEXT NOT NULL DEFAULT '';
ALTER TABLE library ADD COLUMN pid_track TEXT NOT NULL DEFAULT '';
ALTER TABLE library ADD COLUMN scanned_pid_album TEXT NOT NULL DEFAULT '';
ALTER TABLE library ADD COLUMN scanned_pid_track TEXT NOT NULL DEFAULT '';
`)
if err != nil {
return err
}
// Seed from the global properties so existing installs don't full-scan on upgrade
_, err = tx.ExecContext(ctx, `
UPDATE library SET
scanned_pid_album = COALESCE((SELECT value FROM property WHERE id = ?), ?),
scanned_pid_track = COALESCE((SELECT value FROM property WHERE id = ?), ?);
`, consts.PIDAlbumKey, consts.DefaultAlbumPID, consts.PIDTrackKey, consts.DefaultTrackPID)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `DELETE FROM property WHERE id IN (?, ?);`,
consts.PIDAlbumKey, consts.PIDTrackKey)
return err
}
func downAddPerLibraryPid(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
ALTER TABLE library DROP COLUMN pid_album;
ALTER TABLE library DROP COLUMN pid_track;
ALTER TABLE library DROP COLUMN scanned_pid_album;
ALTER TABLE library DROP COLUMN scanned_pid_track;
`)
return err
}
+17 -17
View File
@@ -3,11 +3,11 @@ module github.com/navidrome/navidrome
go 1.27
// Fork to implement raw tags support
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec
require (
github.com/Masterminds/squirrel v1.5.4
github.com/andybalholm/cascadia v1.3.4
github.com/andybalholm/cascadia v1.3.5
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.0.0
github.com/gohugoio/hashstructure v1.1.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.2.0
github.com/mattn/go-sqlite3 v1.14.50
github.com/lestrrat-go/jwx/v3 v3.3.0
github.com/mattn/go-sqlite3 v1.14.52
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.32.1
github.com/onsi/ginkgo/v2 v2.32.2
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.27.3
github.com/pressly/goose/v3 v3.28.0
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.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
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
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.21.1 // indirect
github.com/prometheus/procfs v0.22.0 // 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.55.0 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
+44 -44
View File
@@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
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/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-20260905051825-df1d035571df h1:LdLQVAWVc6hCzqnrfVIEXOhP+r0iSit+EvsXwZDyL70=
github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
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/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.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg=
github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE=
github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4=
github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8=
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.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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/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.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA=
github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw=
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/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.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/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/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.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
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/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.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
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/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.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
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.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/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/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.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/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/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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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/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.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
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/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.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/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/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.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
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.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=
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=
-18
View File
@@ -1,10 +1,8 @@
package model
import (
"cmp"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -27,10 +25,6 @@ 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 (
@@ -44,16 +38,6 @@ 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.
@@ -62,8 +46,6 @@ type LibraryRepository interface {
GetAll(...QueryOptions) (Libraries, error)
CountAll(...QueryOptions) (int64, error)
Put(l *Library, colsToUpdate ...string) error
// UpdateScannedPIDs records the PID specs used by the last completed scan.
UpdateScannedPIDs(id int, album, track string) error
Delete(id int) error
StoreMusicFolder() error
AddArtist(id int, artistID string) error
-41
View File
@@ -1,41 +0,0 @@
package model_test
import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Library", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "album_global"
conf.Server.PID.Track = "track_global"
})
Describe("EffectivePIDAlbum", func() {
It("returns the global config when the override is empty", func() {
lib := model.Library{}
Expect(lib.EffectivePIDAlbum()).To(Equal("album_global"))
})
It("returns the override when it is set", func() {
lib := model.Library{PIDAlbum: "folder"}
Expect(lib.EffectivePIDAlbum()).To(Equal("folder"))
})
})
Describe("EffectivePIDTrack", func() {
It("returns the global config when the override is empty", func() {
lib := model.Library{}
Expect(lib.EffectivePIDTrack()).To(Equal("track_global"))
})
It("returns the override when it is set", func() {
lib := model.Library{PIDTrack: "title"}
Expect(lib.EffectivePIDTrack()).To(Equal("title"))
})
})
})
+6 -6
View File
@@ -8,15 +8,15 @@ import (
"math"
"strconv"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
)
func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile {
pids := pidSpec{Album: lib.EffectivePIDAlbum(), Track: lib.EffectivePIDTrack()}
func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf := model.MediaFile{
LibraryID: lib.ID,
LibraryID: libID,
FolderID: folderID,
Tags: maps.Clone(md.tags),
}
@@ -84,8 +84,8 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
// Persistent IDs
mf.PID = md.trackPID(mf, pids)
mf.AlbumID = md.albumID(mf, pids)
mf.PID = md.trackPID(mf)
mf.AlbumID = md.albumID(mf, conf.Server.PID.Album)
// BFR These IDs will go away once the UI handle multiple participants.
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers
@@ -112,7 +112,7 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi
}
func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string {
return md.albumID(mf, pidSpec{Album: pidConf})
return md.albumID(mf, pidConf)
}
func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
+1 -1
View File
@@ -30,7 +30,7 @@ var _ = Describe("ToMediaFile", func() {
var toMediaFile = func(tags model.RawTags) model.MediaFile {
props.Tags = tags
md = metadata.New("filepath", props)
return md.ToMediaFile(model.Library{ID: 1}, "folderID")
return md.ToMediaFile(1, "folderID")
}
Describe("Dates", func() {
+1 -1
View File
@@ -38,7 +38,7 @@ var _ = Describe("Participants", func() {
var toMediaFile = func(tags model.RawTags) model.MediaFile {
props.Tags = tags
md = metadata.New("filepath", props)
return md.ToMediaFile(model.Library{ID: 1}, "folderID")
return md.ToMediaFile(1, "folderID")
}
Describe("ARTIST(S) tags", func() {
+1 -1
View File
@@ -319,7 +319,7 @@ var _ = Describe("Metadata", func() {
tag: {tagValue},
}
md = metadata.New(filePath, props)
return md.ToMediaFile(model.Library{}, "0")
return md.ToMediaFile(0, "0")
}
DescribeTable("Gain",
+11 -16
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -16,12 +17,6 @@ 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,
@@ -32,7 +27,7 @@ type pidSpec struct {
// 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, pids pidSpec, prependLibId bool, hash hashFunc) string {
func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string {
switch spec {
case "track_legacy":
return legacyTrackID(mf, prependLibId)
@@ -46,7 +41,7 @@ func computePID(mf model.MediaFile, md Metadata, spec string, pids pidSpec, prep
values := make([]string, len(attributes))
hasValue := false
for i, attr := range attributes {
v := getPIDAttr(mf, md, attr, pids, prependLibId, spec, hash)
v := getPIDAttr(mf, md, attr, prependLibId, spec, hash)
if v != "" {
hasValue = true
}
@@ -63,15 +58,15 @@ func computePID(mf model.MediaFile, md Metadata, spec string, pids pidSpec, prep
return hash(pid)
}
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, pids pidSpec, prependLibId bool, spec string, hash hashFunc) string {
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
if spec == pids.Album {
if spec == conf.Server.PID.Album {
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
return ""
}
return computePID(mf, md, pids.Album, pids, prependLibId, hash)
return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash)
case "folder":
return filepath.Dir(mf.Path)
case "albumartistid":
@@ -84,18 +79,18 @@ func getPIDAttr(mf model.MediaFile, md Metadata, attr string, pids pidSpec, prep
return md.String(model.TagName(attr))
}
func (md Metadata) trackPID(mf model.MediaFile, pids pidSpec) string {
return computePID(mf, md, pids.Track, pids, true, id.NewHash)
func (md Metadata) trackPID(mf model.MediaFile) string {
return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash)
}
func (md Metadata) albumID(mf model.MediaFile, pids pidSpec) string {
return computePID(mf, md, pids.Album, pids, true, id.NewHash)
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
return computePID(mf, md, pidConf, true, id.NewHash)
}
// BFR Must be configurable?
func (md Metadata) artistID(name string) string {
mf := model.MediaFile{AlbumArtist: name}
return computePID(mf, md, "albumartistid", pidSpec{}, false, id.NewHash)
return computePID(mf, md, "albumartistid", false, id.NewHash)
}
func (md Metadata) mapTrackTitle() string {
+1 -60
View File
@@ -1,13 +1,10 @@
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"
@@ -21,8 +18,7 @@ var _ = Describe("getPID", func() {
sum hashFunc
)
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
pids := pidSpec{Album: conf.Server.PID.Album, Track: conf.Server.PID.Track}
return computePID(mf, md, spec, pids, prependLibId, sum)
return computePID(mf, md, spec, prependLibId, sum)
}
BeforeEach(func() {
@@ -309,59 +305,4 @@ var _ = Describe("getPID", func() {
})
})
})
Describe("per-library PID specs", func() {
var md Metadata
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = consts.DefaultAlbumPID
conf.Server.PID.Track = consts.DefaultTrackPID
md = New("/music/rock/artist/album/01 - track.mp3", Info{
FileInfo: fakeFileInfo{},
Tags: map[string][]string{
"album": {"An Album"},
"albumartist": {"An Artist"},
"title": {"A Track"},
"tracknumber": {"1"},
},
})
})
It("uses the library's album spec instead of the global config", func() {
tagLib := model.Library{ID: 1}
folderLib := model.Library{ID: 1, PIDAlbum: "folder"}
Expect(md.ToMediaFile(folderLib, "f1").AlbumID).
ToNot(Equal(md.ToMediaFile(tagLib, "f1").AlbumID))
})
It("uses the library's track spec instead of the global config", func() {
defaultLib := model.Library{ID: 1}
titleLib := model.Library{ID: 1, PIDTrack: "title"}
Expect(md.ToMediaFile(titleLib, "f1").PID).
ToNot(Equal(md.ToMediaFile(defaultLib, "f1").PID))
})
It("resolves albumid inside a track spec using the library's album spec", func() {
folderLib := model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}
tagLib := model.Library{ID: 1, PIDTrack: "albumid,title"}
Expect(md.ToMediaFile(folderLib, "f1").PID).
ToNot(Equal(md.ToMediaFile(tagLib, "f1").PID))
})
It("gives two libraries with the same spec different PIDs", func() {
Expect(md.ToMediaFile(model.Library{ID: 1}, "f1").AlbumID).
ToNot(Equal(md.ToMediaFile(model.Library{ID: 2}, "f1").AlbumID))
})
})
})
// fakeFileInfo satisfies FileInfo for tests that just need ToMediaFile to not panic.
type fakeFileInfo struct{ fs.FileInfo }
func (fakeFileInfo) ModTime() time.Time { return time.Time{} }
func (fakeFileInfo) Size() int64 { return 0 }
func (fakeFileInfo) BirthTime() time.Time { return time.Time{} }
-11
View File
@@ -94,8 +94,6 @@ 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})
@@ -134,15 +132,6 @@ ON CONFLICT (user_id, library_id) DO NOTHING;`,
return nil
}
func (r *libraryRepository) UpdateScannedPIDs(id int, album, track string) error {
sq := Update(r.tableName).
Set("scanned_pid_album", album).
Set("scanned_pid_track", track).
Where(Eq{"id": id})
_, err := r.executeSQL(sq)
return err
}
// TODO Remove this method when we have a proper UI to add libraries
// This is a temporary method to store the music folder path from the config in the DB
func (r *libraryRepository) StoreMusicFolder() error {
-47
View File
@@ -270,53 +270,6 @@ var _ = Describe("LibraryRepository", func() {
})
})
Describe("per-library PID columns", func() {
var newLibID int
BeforeEach(func() {
lib := model.Library{Name: "PID Test", Path: "/music/pidtest"}
Expect(repo.Put(&lib)).To(Succeed())
newLibID = lib.ID
})
It("round-trips pid_album and pid_track through Put", func() {
lib, err := repo.Get(newLibID)
Expect(err).ToNot(HaveOccurred())
lib.PIDAlbum = "folder"
lib.PIDTrack = "title"
Expect(repo.Put(lib)).To(Succeed())
got, err := repo.Get(newLibID)
Expect(err).ToNot(HaveOccurred())
Expect(got.PIDAlbum).To(Equal("folder"))
Expect(got.PIDTrack).To(Equal("title"))
})
It("does not let Put overwrite the scanned PID columns", func() {
Expect(repo.UpdateScannedPIDs(newLibID, "scanned_album", "scanned_track")).To(Succeed())
lib, err := repo.Get(newLibID)
Expect(err).ToNot(HaveOccurred())
lib.ScannedPIDAlbum = "clobbered"
lib.ScannedPIDTrack = "clobbered"
Expect(repo.Put(lib)).To(Succeed())
got, err := repo.Get(newLibID)
Expect(err).ToNot(HaveOccurred())
Expect(got.ScannedPIDAlbum).To(Equal("scanned_album"))
Expect(got.ScannedPIDTrack).To(Equal("scanned_track"))
})
It("writes the scanned PID columns via UpdateScannedPIDs", func() {
Expect(repo.UpdateScannedPIDs(newLibID, "a", "t")).To(Succeed())
got, err := repo.Get(newLibID)
Expect(err).ToNot(HaveOccurred())
Expect(got.ScannedPIDAlbum).To(Equal("a"))
Expect(got.ScannedPIDTrack).To(Equal("t"))
})
})
Describe("Delete", func() {
var adminRepo model.LibraryRepository
var artistRepo model.ArtistRepository
-17
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
@@ -315,22 +314,6 @@ func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, t
})
}
// PIDConfChanged reports whether any library's effective PID specs differ from
// the specs recorded by its last completed scan.
func PIDConfChanged(ctx context.Context, ds model.DataStore) (bool, error) {
libs, err := ds.Library(ctx).GetAll()
if err != nil {
return false, err
}
for _, lib := range libs {
if !strings.EqualFold(lib.ScannedPIDAlbum, lib.EffectivePIDAlbum()) ||
!strings.EqualFold(lib.ScannedPIDTrack, lib.EffectivePIDTrack()) {
return true, nil
}
}
return false, nil
}
func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []model.ScanTarget) bool {
return anyIncludedLibrary(ctx, s.ds, targets, func(library model.Library) bool {
return library.LastScanAt.IsZero()
-59
View File
@@ -3,7 +3,6 @@ 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"
@@ -93,61 +92,3 @@ 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())
})
})
+24 -19
View File
@@ -49,13 +49,12 @@ 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)
prevAlbumPIDConf string
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)
lock sync.Mutex
numFolders atomic.Int64
}
func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) {
@@ -82,11 +81,10 @@ 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,
prevAlbumPIDConf: lib.ScannedPIDAlbum,
lib: lib,
fs: fsys,
lastUpdates: lastUpdates,
targetFolders: targetFolders,
}, nil
}
@@ -122,11 +120,12 @@ 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
imageChanges *imageChangeCollector
jobs []*scanJob
ds model.DataStore
ctx context.Context
state *scanState
prevAlbumPIDConf string
imageChanges *imageChangeCollector
}
func (p *phaseFolders) description() string {
@@ -135,6 +134,12 @@ 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
@@ -277,7 +282,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, entry.id)
track := md.ToMediaFile(entry.job.lib.ID, entry.id)
tracks = append(tracks, track)
for _, t := range track.Tags.FlattenAll() {
uniqueTags[t.ID] = t
@@ -288,7 +293,7 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string
if prev := toImport[filePath]; prev != nil {
prevAlbumID = prev.AlbumID
} else {
prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf)
prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf)
}
_, ok := entry.albumIDMap[track.AlbumID]
if prevAlbumID != track.AlbumID && !ok {
-35
View File
@@ -1,35 +0,0 @@
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"))
})
})
+9 -3
View File
@@ -10,6 +10,7 @@ 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"
@@ -335,10 +336,15 @@ 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.Library(ctx).UpdateScannedPIDs(lib.ID, lib.EffectivePIDAlbum(), lib.EffectivePIDTrack())
err = tx.Property(ctx).Put(consts.PIDTrackKey, conf.Server.PID.Track)
if err != nil {
log.Error(ctx, "Scanner: Error updating scanned PID specs", "lib", lib.Name, err)
return fmt.Errorf("updating scanned PID specs: %w", err)
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)
}
if state.changesDetected.Load() {
log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name)
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
"golang.org/x/sync/singleflight"
"github.com/navidrome/navidrome/conf"
@@ -78,9 +77,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-IP throttle when one is configured.
// brute-force surface, so it must share the same per-client throttle when one is configured.
if conf.Server.AuthRequestLimit > 0 {
limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
limiter := server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName)
} else {
inner.Post("/users/authenticatebyname", api.authenticateByName)
+23
View File
@@ -6,6 +6,7 @@ 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"
@@ -84,4 +85,26 @@ var _ = Describe("Router", func() {
Expect(login()).To(Equal(http.StatusUnauthorized))
Expect(login()).To(Equal(http.StatusTooManyRequests))
})
It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 1
conf.Server.AuthWindowLength = time.Minute
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Every request arrives on the same proxy connection, so only the resolved client IP
// can separate the buckets.
handler := middleware.ClientIPFromHeader("X-Real-IP")(api)
login := func(clientIP string) int {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`))
r.RemoteAddr = "10.0.0.1:1234"
r.Header.Set("X-Real-IP", clientIP)
handler.ServeHTTP(w, r)
return w.Code
}
Expect(login("203.0.113.1")).To(Equal(http.StatusUnauthorized))
Expect(login("203.0.113.1")).To(Equal(http.StatusTooManyRequests))
Expect(login("203.0.113.2")).To(Equal(http.StatusUnauthorized))
})
})
+1 -1
View File
@@ -136,7 +136,7 @@ func isSameMachine(r *http.Request, remote netip.Addr) bool {
return parseIP(local.String()) == remote
}
// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP.
// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP.
func remoteIP(r *http.Request) netip.Addr {
return parseIP(r.RemoteAddr)
}
+71 -11
View File
@@ -7,7 +7,9 @@ import (
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
@@ -15,6 +17,7 @@ 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"
@@ -165,20 +168,77 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
})
}
// 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.
// 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.
func realIPMiddleware(next http.Handler) http.Handler {
if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP,
).Handler(next)
trusted := conf.Server.ExtAuth.TrustedSources
fromPeer := middleware.ClientIPFromRemoteAddr(next)
if trusted == "" {
return fromPeer
}
// 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)
// 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
})
}
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
+97
View File
@@ -9,6 +9,7 @@ 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"
@@ -435,4 +436,100 @@ 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))
})
})
})
})
+1 -5
View File
@@ -22,11 +22,7 @@ func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.Inspec
return nil, model.ErrNotFound
}
lib, err := ds.Library(ctx).Get(file.LibraryID)
if err != nil {
return nil, err
}
return core.Inspect(file.AbsolutePath(), *lib, file.FolderID)
return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID)
}
func inspect(ds model.DataStore) http.HandlerFunc {
+1 -2
View File
@@ -17,7 +17,6 @@ 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"
@@ -209,7 +208,7 @@ func (s *Server) mountAuthenticationRoutes() chi.Router {
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
"windowLength", conf.Server.AuthWindowLength)
rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
rateLimiter := ClientIPRateLimiter(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,3 +205,76 @@ 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())
})
})
+25 -7
View File
@@ -1,11 +1,13 @@
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"
@@ -60,9 +62,10 @@ 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,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
Description: description,
Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads),
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
}
id, err := repo.(rest.Persistable).Save(share)
@@ -87,12 +90,27 @@ 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,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ID: id,
Description: *description,
Downloadable: *downloadable,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
}
err = repo.(rest.Persistable).Update(id, share)
+16 -9
View File
@@ -280,12 +280,19 @@ 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) {
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 {
clientName := clientInfo.Name
if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" {
if hasPlayer && player.Client != "" {
clientName = player.Client
}
log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation",
@@ -293,13 +300,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
}
}
// 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)
// 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)
}
// Get media file
+43 -2
View File
@@ -369,7 +369,7 @@ var _ = Describe("Transcode endpoints", func() {
mockTD.token = "token"
})
It("forces a supported format and clears direct play", func() {
It("forces a supported format and narrows direct play to it", func() {
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
"transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"},
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
@@ -380,7 +380,11 @@ 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(BeEmpty())
Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{
Containers: []string{"ogg"},
AudioCodecs: []string{"opus"},
Protocols: []string{"http"},
}))
})
It("falls back to negotiation when the forced format is unsupported", func() {
@@ -416,6 +420,43 @@ var _ = Describe("Transcode endpoints", func() {
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128))
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128))
})
withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request {
ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate})
ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate})
return r.WithContext(ctx)
}
It("applies the transcoding default bitrate when the player sets no maxBitRate", func() {
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192))
})
It("prefers the player maxBitRate over the transcoding default bitrate", func() {
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
})
It("ignores the transcoding default bitrate when the forced format is unsupported", func() {
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero())
})
})
})
-12
View File
@@ -106,18 +106,6 @@ 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
+3 -9
View File
@@ -1,14 +1,8 @@
import React from 'react'
import Link from '@material-ui/core/Link'
import { docsUrl } from '../utils'
export const DocLink = ({ path, children, ...rest }) => (
<Link
href={docsUrl(path)}
target="_blank"
rel="noopener noreferrer"
{...rest}
>
export const DocLink = ({ path, children }) => (
<a href={docsUrl(path)} target={'_blank'} rel="noopener noreferrer">
{children}
</Link>
</a>
)
+8 -1
View File
@@ -1,6 +1,7 @@
export const REST_URL = '/api'
export const INSIGHTS_DOC_PATH = '/docs/getting-started/insights'
export const INSIGHTS_DOC_URL =
'https://navidrome.org/docs/getting-started/insights'
export const M3U_MIME_TYPE = 'audio/x-mpegurl'
@@ -30,3 +31,9 @@ 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,
]
+2 -3
View File
@@ -18,10 +18,9 @@ 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_PATH } from '../consts.js'
import { INSIGHTS_DOC_URL } from '../consts.js'
import subsonic from '../subsonic/index.js'
import { Typography } from '@material-ui/core'
import TableHead from '@material-ui/core/TableHead'
@@ -190,7 +189,7 @@ const AboutTabContent = ({
{translate(`about.links.lastInsightsCollection`)}:
</TableCell>
<TableCell align="left">
<DocLink path={INSIGHTS_DOC_PATH}>{insightsStatus}</DocLink>
<Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link>
</TableCell>
</TableRow>
) : null}
+6 -18
View File
@@ -200,6 +200,9 @@
"targetFormat": "Target Format",
"defaultBitRate": "Default Bit Rate",
"command": "Command"
},
"choices": {
"noDefaultBitRate": "None"
}
},
"playlist": {
@@ -304,14 +307,11 @@
"totalDuration": "Duration",
"defaultNewUsers": "Default for New Users",
"createdAt": "Created",
"updatedAt": "Updated",
"pidAlbum": "Album Grouping",
"pidTrack": "Track Identity"
"updatedAt": "Updated"
},
"sections": {
"basic": "Basic Information",
"statistics": "Statistics",
"persistentIds": "Persistent IDs"
"statistics": "Statistics"
},
"actions": {
"scan": "Scan Library",
@@ -336,24 +336,12 @@
"pathNotDirectory": "Library path must be a directory",
"pathNotFound": "Library path not found",
"pathNotAccessible": "Library path is not accessible",
"pathInvalid": "Invalid library path",
"pidAlbumRecursive": "Album grouping cannot reference albumid",
"pidAlbumCustomRequired": "A custom album grouping spec is required"
"pathInvalid": "Invalid library path"
},
"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": {
+10 -8
View File
@@ -6,6 +6,7 @@ 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 {
@@ -21,8 +22,7 @@ import Notification from './Notification'
import useCurrentTheme from '../themes/useCurrentTheme'
import config from '../config'
import { clearQueue } from '../actions'
import { INSIGHTS_DOC_PATH } from '../consts.js'
import { DocLink } from '../common'
import { INSIGHTS_DOC_URL } from '../consts.js'
const useStyles = makeStyles(
(theme) => ({
@@ -186,7 +186,7 @@ const FormLogin = ({ loading, handleSubmit, validate }) => {
)
}
const InsightsNotice = ({ path }) => {
const InsightsNotice = ({ url }) => {
const translate = useTranslate()
const classes = useStyles()
@@ -210,15 +210,17 @@ const InsightsNotice = ({ path }) => {
// Push the text before the bracket
segments.push(line.slice(lastIndex, match.index))
// Push the <DocLink> component
// Push the <Link> component
segments.push(
<DocLink
path={path}
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
key={`${lineIndex}-${match.index}`}
style={{ cursor: 'pointer' }}
>
{bracketText}
</DocLink>,
</Link>,
)
// Update lastIndex to the character right after the bracketed text
@@ -304,7 +306,7 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => {
{translate('ra.auth.buttonCreateAdmin')}
</Button>
</CardActions>
<InsightsNotice path={INSIGHTS_DOC_PATH} />
<InsightsNotice url={INSIGHTS_DOC_URL} />
</Card>
<Notification />
</div>
+1 -25
View File
@@ -10,10 +10,7 @@ import {
useNotify,
useRedirect,
} from 'react-admin'
import { Typography, Box } from '@material-ui/core'
import { Title, DocLink } from '../common'
import PIDAlbumInput from './PIDAlbumInput'
import { parsePidField, PID_DOCS_PATH } from './pidUtils'
import { Title } from '../common'
const LibraryCreate = (props) => {
const translate = useTranslate()
@@ -79,27 +76,6 @@ 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>
)
+183 -243
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'
import React, { useCallback } from 'react'
import {
Edit,
FormWithRedirect,
@@ -12,15 +12,11 @@ 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 PIDAlbumInput from './PIDAlbumInput'
import { pidChanged, parsePidField, PID_DOCS_PATH } from './pidUtils'
import { Title, DocLink } from '../common'
import { Title } from '../common'
import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js'
const useStyles = makeStyles({
@@ -51,16 +47,17 @@ const CustomToolbar = ({ showDelete, ...props }) => (
</Toolbar>
)
// 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 LibraryEdit = (props) => {
const translate = useTranslate()
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
const [pendingValues, setPendingValues] = useState(null)
const doSave = useCallback(
// Library ID 1 is protected (main library)
const canDelete = props.id !== '1'
const canEditPath = props.id !== '1'
const save = useCallback(
async (values) => {
try {
await mutate(
@@ -84,248 +81,191 @@ const LibraryEditForm = ({ canDelete, canEditPath, record, ...formProps }) => {
[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 (
<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"
/>
<Box mt="2em" />
<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
variant="outlined"
/>
<Box mt="2em" />
{/* 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 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')}
<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>
<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}
<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>
<Box mb="2em">
<Typography
variant="body2"
color="textSecondary"
gutterBottom
>
{translate('resources.library.fields.createdAt')}
<Box mt="2em" />
{/* Statistics - Two Column Layout */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.statistics')}
</Typography>
<DateField
variant="body1"
source="createdAt"
showTime
record={formRenderProps.record}
/>
<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>
<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={formProps.record}
/>
</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>
</Box>
</Box>
</Box>
</Box>
<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} />
<CustomToolbar
handleSubmitWithRedirect={formProps.handleSubmitWithRedirect}
pristine={formProps.pristine}
saving={formProps.saving}
record={formProps.record}
showDelete={canDelete}
/>
</form>
)}
/>
</Edit>
)
}
-156
View File
@@ -1,156 +0,0 @@
import * as React from 'react'
import { act, render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import LibraryEdit from './LibraryEdit'
const hooks = vi.hoisted(() => ({
record: null,
mutate: vi.fn(),
notify: vi.fn(),
redirect: vi.fn(),
save: null,
confirmOnConfirm: null,
confirmOnClose: null,
}))
// `Edit` mirrors ra-ui-materialui's EditView: the fetched record is injected
// into the direct child only, never into the props of whoever renders <Edit>.
vi.mock('react-admin', () => ({
Edit: ({ children }) =>
React.cloneElement(React.Children.only(children), {
record: hooks.record,
}),
FormWithRedirect: ({ record, save, render: renderForm }) => {
hooks.save = save
return renderForm({
record,
handleSubmit: () => {},
pristine: false,
saving: false,
})
},
TextInput: ({ source }) => <input readOnly data-testid={`input-${source}`} />,
BooleanInput: ({ source }) => (
<input type="checkbox" readOnly data-testid={`input-${source}`} />
),
required: () => () => null,
SaveButton: () => <button data-testid="save-button">Save</button>,
DateField: ({ source }) => <div data-testid={`date-${source}`} />,
useTranslate: () => (key) => key,
useMutation: () => [hooks.mutate],
useNotify: () => hooks.notify,
useRedirect: () => hooks.redirect,
Toolbar: ({ children }) => <div data-testid="toolbar">{children}</div>,
Confirm: ({ isOpen, onConfirm, onClose }) => {
hooks.confirmOnConfirm = onConfirm
hooks.confirmOnClose = onClose
return isOpen ? <div data-testid="confirm-dialog" /> : null
},
}))
vi.mock('./PIDAlbumInput', () => ({
__esModule: true,
default: () => <div data-testid="pid-album-input" />,
}))
vi.mock('./DeleteLibraryButton', () => ({
__esModule: true,
default: () => <button data-testid="delete-library-button">Delete</button>,
}))
vi.mock('../common', () => ({
Title: () => <div data-testid="title" />,
DocLink: ({ children }) => <a href="#doc">{children}</a>,
}))
vi.mock('@material-ui/core/styles', () => ({
makeStyles: () => () => ({}),
}))
vi.mock('@material-ui/core', () => ({
Typography: ({ children }) => <p>{children}</p>,
Box: ({ children }) => <div>{children}</div>,
}))
describe('LibraryEdit save guard', () => {
beforeEach(() => {
vi.clearAllMocks()
hooks.save = null
hooks.confirmOnConfirm = null
hooks.confirmOnClose = null
})
it('saves directly when the PID spec did not change', async () => {
hooks.record = { id: '1', name: 'Library 1', pidAlbum: 'folder' }
render(<LibraryEdit id="1" />)
await act(async () => {
hooks.save({ id: '1', name: 'Library 1 renamed', pidAlbum: 'folder' })
})
expect(hooks.mutate).toHaveBeenCalled()
expect(screen.queryByTestId('confirm-dialog')).toBeNull()
})
it('opens the confirm dialog when pidAlbum changes from empty to folder', async () => {
hooks.record = { id: '1', name: 'Library 1', pidAlbum: '' }
render(<LibraryEdit id="1" />)
await act(async () => {
hooks.save({ id: '1', name: 'Library 1', pidAlbum: 'folder' })
})
expect(hooks.mutate).not.toHaveBeenCalled()
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
it('opens the confirm dialog when pidAlbum reverts from folder to empty', async () => {
hooks.record = { id: '1', name: 'Library 1', pidAlbum: 'folder' }
render(<LibraryEdit id="1" />)
await act(async () => {
hooks.save({ id: '1', name: 'Library 1', pidAlbum: '' })
})
expect(hooks.mutate).not.toHaveBeenCalled()
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
it('does not save on cancel, and leaves the form usable for another save', async () => {
hooks.record = { id: '1', name: 'Library 1', pidAlbum: '' }
render(<LibraryEdit id="1" />)
await act(async () => {
hooks.save({ id: '1', name: 'Library 1', pidAlbum: 'folder' })
})
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
await act(async () => {
hooks.confirmOnClose()
})
expect(hooks.mutate).not.toHaveBeenCalled()
expect(screen.queryByTestId('confirm-dialog')).toBeNull()
await act(async () => {
hooks.save({ id: '1', name: 'Library 1', pidAlbum: '' })
})
expect(hooks.mutate).toHaveBeenCalled()
})
it('calls the mutation when the dialog is confirmed', async () => {
hooks.record = { id: '1', name: 'Library 1', pidAlbum: '' }
render(<LibraryEdit id="1" />)
await act(async () => {
hooks.save({ id: '1', name: 'Library 1', pidAlbum: 'folder' })
})
await act(async () => {
hooks.confirmOnConfirm()
})
expect(hooks.mutate).toHaveBeenCalled()
expect(screen.queryByTestId('confirm-dialog')).toBeNull()
})
})
-68
View File
@@ -1,68 +0,0 @@
import React, { useState } from 'react'
import { TextInput, required, useTranslate } from 'react-admin'
import { useField } from 'react-final-form'
import { MenuItem, TextField } from '@material-ui/core'
import {
PID_CUSTOM,
PID_DEFAULT,
PID_FOLDER,
pidAlbumMode,
parsePidField,
} from './pidUtils'
const PIDAlbumInput = () => {
const translate = useTranslate()
// Keeps pidAlbum registered in every mode, otherwise final-form's pristine
// flag never clears when Default/Folder is chosen (no field is registered).
const { input } = useField('pidAlbum', { parse: parsePidField })
const [mode, setMode] = useState(() => pidAlbumMode(input.value))
const handleChange = (event) => {
const next = event.target.value
setMode(next)
input.onChange(next === PID_FOLDER ? PID_FOLDER : '')
}
return (
<>
<TextField
select
fullWidth
variant="outlined"
margin="dense"
value={mode}
onChange={handleChange}
label={translate('resources.library.fields.pidAlbum')}
SelectProps={{
SelectDisplayProps: { 'data-testid': 'pidAlbum-mode-select' },
}}
>
<MenuItem value={PID_DEFAULT}>
{translate('resources.library.pid.default')}
</MenuItem>
<MenuItem value={PID_FOLDER}>
{translate('resources.library.pid.folder')}
</MenuItem>
<MenuItem value={PID_CUSTOM}>
{translate('resources.library.pid.custom')}
</MenuItem>
</TextField>
{mode === PID_CUSTOM && (
<TextInput
source="pidAlbum"
label={translate('resources.library.fields.pidAlbum')}
helperText={translate('resources.library.pid.customHelp')}
validate={[
required('resources.library.validation.pidAlbumCustomRequired'),
]}
parse={parsePidField}
data-testid="pidAlbum-custom-input"
fullWidth
variant="outlined"
/>
)}
</>
)
}
export default PIDAlbumInput
-145
View File
@@ -1,145 +0,0 @@
import React from 'react'
import {
render,
screen,
fireEvent,
within,
waitFor,
} from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Form, FormSpy } from 'react-final-form'
import { TestContext } from 'ra-test'
import PIDAlbumInput from './PIDAlbumInput'
const renderWithForm = (initialValues, onSubmit = () => {}) =>
render(
<TestContext>
<Form
onSubmit={onSubmit}
initialValues={initialValues}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<PIDAlbumInput />
<button type="submit">save</button>
</form>
)}
/>
</TestContext>,
)
const renderWithPristineTracking = (initialValues) => {
const pristineHistory = []
render(
<TestContext>
<Form
onSubmit={() => {}}
initialValues={initialValues}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<PIDAlbumInput />
<FormSpy
subscription={{ pristine: true }}
onChange={({ pristine }) => pristineHistory.push(pristine)}
/>
</form>
)}
/>
</TestContext>,
)
return pristineHistory
}
describe('PIDAlbumInput', () => {
it('hides the custom text box when the value is empty', () => {
renderWithForm({ pidAlbum: '' })
expect(screen.queryByTestId('pidAlbum-custom-input')).toBeNull()
})
it('hides the custom text box when the value is folder', () => {
renderWithForm({ pidAlbum: 'folder' })
expect(screen.queryByTestId('pidAlbum-custom-input')).toBeNull()
})
it('shows the custom text box for an unrecognized value', () => {
renderWithForm({ pidAlbum: 'musicbrainz_albumid' })
expect(screen.getByTestId('pidAlbum-custom-input')).toBeInTheDocument()
expect(screen.getByDisplayValue('musicbrainz_albumid')).toBeInTheDocument()
})
it('blocks submit when the custom value is emptied', () => {
const onSubmit = vi.fn()
renderWithForm({ pidAlbum: 'musicbrainz_albumid' }, onSubmit)
const input = screen
.getByTestId('pidAlbum-custom-input')
.querySelector('input')
fireEvent.change(input, { target: { value: '' } })
fireEvent.click(screen.getByText('save'))
expect(onSubmit).not.toHaveBeenCalled()
expect(
screen.getByText('resources.library.validation.pidAlbumCustomRequired'),
).toBeInTheDocument()
})
it('clears form pristine when switching to Folder-based mode', async () => {
const pristineHistory = renderWithPristineTracking({ pidAlbum: '' })
expect(pristineHistory[pristineHistory.length - 1]).toBe(true)
fireEvent.mouseDown(screen.getByTestId('pidAlbum-mode-select'))
fireEvent.click(
within(screen.getByRole('listbox')).getByText(
'resources.library.pid.folder',
),
)
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(false),
)
})
it('returns to pristine when the mode is changed back to the original value', async () => {
const pristineHistory = renderWithPristineTracking({ pidAlbum: '' })
fireEvent.mouseDown(screen.getByTestId('pidAlbum-mode-select'))
fireEvent.click(
within(screen.getByRole('listbox')).getByText(
'resources.library.pid.folder',
),
)
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(false),
)
fireEvent.mouseDown(screen.getByTestId('pidAlbum-mode-select'))
fireEvent.click(
within(screen.getByRole('listbox')).getByText(
'resources.library.pid.default',
),
)
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(true),
)
})
it('returns to pristine when a custom value is reverted back to the original value', async () => {
const pristineHistory = renderWithPristineTracking({
pidAlbum: 'musicbrainz_albumid',
})
const input = screen
.getByTestId('pidAlbum-custom-input')
.querySelector('input')
fireEvent.change(input, { target: { value: 'other_tag' } })
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(false),
)
fireEvent.change(input, { target: { value: 'musicbrainz_albumid' } })
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(true),
)
})
})
-48
View File
@@ -1,48 +0,0 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Form, FormSpy } from 'react-final-form'
import { TestContext } from 'ra-test'
import { TextInput } from 'react-admin'
import { parsePidField } from './pidUtils'
// Mirrors the pidTrack TextInput used in LibraryEdit.jsx / LibraryCreate.jsx.
const renderPidTrackField = (initialValues) => {
const pristineHistory = []
render(
<TestContext>
<Form
onSubmit={() => {}}
initialValues={initialValues}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<TextInput source="pidTrack" parse={parsePidField} />
<FormSpy
subscription={{ pristine: true }}
onChange={({ pristine }) => pristineHistory.push(pristine)}
/>
</form>
)}
/>
</TestContext>,
)
return pristineHistory
}
describe('pidTrack field pristine behavior', () => {
it('returns to pristine when the value is reverted back to the original empty value', async () => {
const pristineHistory = renderPidTrackField({ pidTrack: '' })
expect(pristineHistory[pristineHistory.length - 1]).toBe(true)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'title' } })
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(false),
)
fireEvent.change(input, { target: { value: '' } })
await waitFor(() =>
expect(pristineHistory[pristineHistory.length - 1]).toBe(true),
)
})
})
-19
View File
@@ -1,19 +0,0 @@
export const PID_DEFAULT = ''
export const PID_FOLDER = 'folder'
export const PID_CUSTOM = '__custom__'
export const pidAlbumMode = (value) => {
if (!value) return PID_DEFAULT
if (value === PID_FOLDER) return PID_FOLDER
return PID_CUSTOM
}
export const pidChanged = (record = {}, values = {}) =>
(record.pidAlbum || '') !== (values.pidAlbum || '') ||
(record.pidTrack || '') !== (values.pidTrack || '')
// react-final-form's default parse turns '' into undefined, which deletes the
// key from values and leaves the field permanently dirty against a '' initialValue.
export const parsePidField = (value) => value ?? ''
export const PID_DOCS_PATH = '/docs/usage/configuration/persistent-ids/'
-26
View File
@@ -1,26 +0,0 @@
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)
})
})
+5 -1
View File
@@ -59,7 +59,11 @@ const useCurrentTheme = () => {
return useMemo(
() => ({
...theme,
props: { ...theme.props, MuiUseMediaQuery: { noSsr: true } },
props: {
...theme.props,
MuiUseMediaQuery: { noSsr: true },
MuiPopover: { disableScrollLock: true },
},
}),
[theme],
)
+2 -2
View File
@@ -8,7 +8,7 @@ import {
useTranslate,
} from 'react-admin'
import { Title } from '../common'
import { BITRATE_CHOICES } from '../consts'
import { TRANSCODING_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={BITRATE_CHOICES}
choices={TRANSCODING_BITRATE_CHOICES}
defaultValue={192}
/>
<TextInput
+5 -2
View File
@@ -9,7 +9,7 @@ import {
} from 'react-admin'
import { Title } from '../common'
import { TranscodingNote } from './TranscodingNote'
import { BITRATE_CHOICES } from '../consts'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = ({ record }) => {
const translate = useTranslate()
@@ -28,7 +28,10 @@ const TranscodingEdit = (props) => {
<SimpleForm variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<TextInput source="targetFormat" validate={[required()]} />
<SelectInput source="defaultBitRate" choices={BITRATE_CHOICES} />
<SelectInput
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextInput source="command" fullWidth validate={[required()]} />
</SimpleForm>
</Edit>
+13 -3
View File
@@ -1,7 +1,8 @@
import React from 'react'
import { Datagrid, TextField } from 'react-admin'
import { Datagrid, SelectField, 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) => {
@@ -16,13 +17,22 @@ const TranscodingList = (props) => {
<SimpleList
primaryText={(r) => r.name}
secondaryText={(r) => `format: ${r.targetFormat}`}
tertiaryText={(r) => r.defaultBitRate}
tertiaryText={(r) => (
<SelectField
record={r}
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
)}
/>
) : (
<Datagrid rowClick={config.enableTranscodingConfig ? 'edit' : 'show'}>
<TextField source="name" />
<TextField source="targetFormat" />
<TextField source="defaultBitRate" />
<SelectField
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextField source="command" />
</Datagrid>
)}
+6 -2
View File
@@ -1,7 +1,8 @@
import React from 'react'
import { Show, SimpleShowLayout, TextField } from 'react-admin'
import { SelectField, 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 : ''}`} />
@@ -16,7 +17,10 @@ const TranscodingShow = (props) => {
<SimpleShowLayout>
<TextField source="name" />
<TextField source="targetFormat" />
<TextField source="defaultBitRate" />
<SelectField
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextField source="command" />
</SimpleShowLayout>
</Show>