diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml
index 760a0528b..b8cde4aaf 100644
--- a/.github/actions/prepare-docker/action.yml
+++ b/.github/actions/prepare-docker/action.yml
@@ -53,13 +53,13 @@ runs:
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -67,12 +67,13 @@ runs:
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Extract metadata for Docker image
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
+ github-token: ${{ inputs.github_token }}
labels: |
maintainer=deluan@navidrome.org
images: |
diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml
index 38b7b8a86..076f963d4 100644
--- a/.github/workflows/download-link-on-pr.yml
+++ b/.github/workflows/download-link-on-pr.yml
@@ -8,7 +8,7 @@ jobs:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- - uses: actions/github-script@v3
+ - uses: actions/github-script@v7
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
@@ -19,8 +19,7 @@ jobs:
const pull_user_id = ${{github.event.sender.id}};
const issue_number = await (async () => {
- const pulls = await github.pulls.list({owner, repo});
- for await (const {data} of github.paginate.iterator(pulls)) {
+ for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
for (const pull of data) {
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
return pull.number;
@@ -34,7 +33,7 @@ jobs:
return core.error(`No matching pull request found`);
}
- const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
+ const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
@@ -43,12 +42,12 @@ jobs:
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
- const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
+ const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
if (existing_comment) {
core.info(`Updating comment ${existing_comment.id}`);
- await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
+ await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
} else {
core.info(`Creating a comment`);
- await github.issues.createComment({repo, owner, issue_number, body});
+ await github.rest.issues.createComment({repo, owner, issue_number, body});
}
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 09fca2572..6f858a5a7 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -145,7 +145,7 @@ jobs:
- name: Cache ffmpeg
id: ffmpeg-cache
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: C:\ffmpeg
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 69e6ac99e..33c8fadbd 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -28,7 +28,7 @@ jobs:
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- - uses: actions/stale@v9
+ - uses: actions/stale@v10
with:
operations-per-run: 999
days-before-issue-stale: 180
diff --git a/cmd/backup.go b/cmd/backup.go
index ab73f7537..c02f3a19f 100644
--- a/cmd/backup.go
+++ b/cmd/backup.go
@@ -75,7 +75,7 @@ var (
func runBackup(ctx context.Context) {
if backupDir != "" {
- conf.Server.Backup.Path = backupDir
+ conf.Server.Backup.Path = conf.NewDir(backupDir)
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
@@ -104,7 +104,7 @@ func runBackup(ctx context.Context) {
func runPrune(ctx context.Context) {
if backupDir != "" {
- conf.Server.Backup.Path = backupDir
+ conf.Server.Backup.Path = conf.NewDir(backupDir)
}
if backupCount != -1 {
diff --git a/cmd/svc.go b/cmd/svc.go
index 89ca08056..cc8d6bb54 100644
--- a/cmd/svc.go
+++ b/cmd/svc.go
@@ -76,13 +76,13 @@ var svcInstance = sync.OnceValue(func() service.Service {
options["Restart"] = "on-failure"
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
options["UserService"] = false
- options["LogDirectory"] = conf.Server.DataFolder
+ options["LogDirectory"] = conf.Server.DataFolder.String()
options["SystemdScript"] = systemdScript
if conf.Server.LogFile != "" {
options["LogOutput"] = false
} else {
options["LogOutput"] = true
- options["LogDirectory"] = conf.Server.DataFolder
+ options["LogDirectory"] = conf.Server.DataFolder.String()
}
svcConfig := &service.Config{
UserName: installUser,
@@ -131,11 +131,11 @@ func buildInstallCmd() *cobra.Command {
println("Installing service with:")
println(" working directory: " + executablePath())
println(" music folder: " + conf.Server.MusicFolder)
- println(" data folder: " + conf.Server.DataFolder)
+ println(" data folder: " + conf.Server.DataFolder.String())
if conf.Server.LogFile != "" {
println(" log file: " + conf.Server.LogFile)
} else {
- println(" logs folder: " + conf.Server.DataFolder)
+ println(" logs folder: " + conf.Server.DataFolder.String())
}
if cfgFile != "" {
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
diff --git a/conf/configtest/configtest.go b/conf/configtest/configtest.go
index b947e6263..cd0ac41ed 100644
--- a/conf/configtest/configtest.go
+++ b/conf/configtest/configtest.go
@@ -2,9 +2,7 @@ package configtest
import "github.com/navidrome/navidrome/conf"
+// TODO Remove this redirection and call SnapshotConfig directly from tests
func SetupConfig() func() {
- oldValues := *conf.Server
- return func() {
- conf.Server = &oldValues
- }
+ return conf.SnapshotConfig()
}
diff --git a/conf/configuration.go b/conf/configuration.go
index 251bb0248..d8dc03b0c 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -2,6 +2,7 @@ package conf
import (
"cmp"
+ "encoding/json"
"fmt"
"net/url"
"os"
@@ -14,6 +15,7 @@ import (
"github.com/bmatcuk/doublestar/v4"
"github.com/dustin/go-humanize"
"github.com/go-viper/encoding/ini"
+ "github.com/go-viper/mapstructure/v2"
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@@ -29,8 +31,8 @@ type configOptions struct {
UnixSocketPerm string
EnforceNonRootUser bool
MusicFolder string
- DataFolder string
- CacheFolder string
+ DataFolder Dir
+ CacheFolder Dir
DbPath string
LogLevel string
LogFile string
@@ -134,6 +136,7 @@ type configOptions struct {
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
+ DevArtworkThrottleBuffered bool
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
@@ -229,7 +232,7 @@ type jukeboxOptions struct {
type backupOptions struct {
Count int
- Path string
+ Path Dir
Schedule string
}
@@ -247,7 +250,7 @@ type inspectOptions struct {
type pluginsOptions struct {
Enabled bool
- Folder string
+ Folder Dir
CacheSize string
AutoReload bool
LogLevel string
@@ -287,6 +290,22 @@ var (
hooks []func()
)
+// SnapshotConfig returns a function that restores Server to its current state.
+// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
+func SnapshotConfig() func() {
+ snapshot, err := json.Marshal(Server)
+ if err != nil {
+ panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
+ }
+ return func() {
+ var restored configOptions
+ if err := json.Unmarshal(snapshot, &restored); err != nil {
+ panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
+ }
+ Server = &restored
+ }
+}
+
func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
@@ -307,7 +326,13 @@ func Load(noConfigDump bool) {
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
- err := viper.Unmarshal(&Server)
+ err := viper.Unmarshal(&Server, viper.DecodeHook(
+ mapstructure.ComposeDecodeHookFunc(
+ mapstructure.TextUnmarshallerHookFunc(),
+ mapstructure.StringToTimeDurationHookFunc(),
+ mapstructure.StringToSliceHookFunc(","),
+ ),
+ ))
if err != nil {
logFatal("Error parsing config:", err)
}
@@ -317,48 +342,28 @@ func Load(noConfigDump bool) {
logFatal(err)
}
- err = os.MkdirAll(Server.DataFolder, os.ModePerm)
- if err != nil {
- logFatal("Error creating data path:", err)
- }
-
- if Server.CacheFolder == "" {
- Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
- }
- err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
- if err != nil {
- logFatal("Error creating cache path:", err)
- }
-
- err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
- if err != nil {
- logFatal("Error creating artwork path:", err)
+ if Server.CacheFolder.String() == "" {
+ Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
}
if Server.Plugins.Enabled {
- if Server.Plugins.Folder == "" {
- Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
- }
- err = os.MkdirAll(Server.Plugins.Folder, 0700)
- if err != nil {
- logFatal("Error creating plugins path:", err)
+ if Server.Plugins.Folder.String() == "" {
+ Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700)
+ } else {
+ Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700)
}
}
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
if Server.DbPath == "" {
- Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
- }
-
- if Server.Backup.Path != "" {
- err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
- if err != nil {
- logFatal("Error creating backup path:", err)
- }
+ Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath)
}
out := os.Stderr
if Server.LogFile != "" {
+ if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
+ logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
+ }
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
@@ -636,7 +641,7 @@ func validateScanSchedule() error {
}
func validateBackupSchedule() error {
- if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
+ if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
Server.Backup.Schedule = ""
return nil
}
@@ -863,6 +868,7 @@ func setViperDefaults() {
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
+ viper.SetDefault("devartworkthrottlebuffered", true)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devexternalscanner", true)
diff --git a/conf/configuration_test.go b/conf/configuration_test.go
index 5d4e73fad..9c25a0d19 100644
--- a/conf/configuration_test.go
+++ b/conf/configuration_test.go
@@ -186,27 +186,12 @@ var _ = Describe("Configuration", func() {
}).To(PanicWith(ContainSubstring("Error reading config file")))
})
- It("is called when DataFolder is not writable", func() {
- viper.SetDefault("datafolder", invalidPath)
- Expect(func() {
- conf.Load(true)
- }).To(PanicWith(ContainSubstring("Error creating data path")))
- })
-
- It("is called when CacheFolder is not writable", func() {
- viper.SetDefault("datafolder", GinkgoT().TempDir())
- viper.SetDefault("cachefolder", invalidPath)
- Expect(func() {
- conf.Load(true)
- }).To(PanicWith(ContainSubstring("Error creating cache path")))
- })
-
It("is called when LogFile path is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
Expect(func() {
conf.Load(true)
- }).To(PanicWith(ContainSubstring("Error opening log file")))
+ }).To(PanicWith(ContainSubstring("Error creating log file directory")))
})
It("is called when BaseURL is invalid", func() {
diff --git a/conf/dir.go b/conf/dir.go
new file mode 100644
index 000000000..8ed43039b
--- /dev/null
+++ b/conf/dir.go
@@ -0,0 +1,76 @@
+package conf
+
+import (
+ "fmt"
+ "os"
+ "sync"
+)
+
+// Dir wraps a directory path and lazily creates the directory on first use.
+// The directory is created at most once; if creation fails, the error is
+// permanently cached (sync.Once semantics). Dir is not safe for mutation
+// after Path() has been called.
+type Dir struct {
+ path string
+ perm os.FileMode
+ once sync.Once
+ err error
+}
+
+// NewDir creates a new Dir with the given path and default permissions (os.ModePerm).
+func NewDir(path string) Dir {
+ return Dir{path: path, perm: os.ModePerm}
+}
+
+// NewDirWithPerm creates a new Dir with the given path and permissions.
+func NewDirWithPerm(path string, perm os.FileMode) Dir {
+ return Dir{path: path, perm: perm}
+}
+
+// String returns the raw path without creating the directory. Satisfies fmt.Stringer.
+func (d *Dir) String() string {
+ return d.path
+}
+
+// Path creates the directory on first call (via sync.Once) and returns the path.
+func (d *Dir) Path() (string, error) {
+ d.once.Do(func() {
+ if d.path == "" {
+ return
+ }
+ d.err = os.MkdirAll(d.path, d.perm)
+ if d.err != nil {
+ d.err = fmt.Errorf("creating directory %q: %w", d.path, d.err)
+ }
+ })
+ return d.path, d.err
+}
+
+// MustPath calls Path() and calls logFatal on error.
+func (d *Dir) MustPath() string {
+ path, err := d.Path()
+ if err != nil {
+ logFatal("creating directory:", err)
+ }
+ return path
+}
+
+// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf)
+// prints the path string instead of the internal struct fields.
+func (d Dir) GoString() string { //nolint:govet
+ return fmt.Sprintf("%q", d.path)
+}
+
+// MarshalText returns the raw path bytes. No side effects.
+func (d *Dir) MarshalText() ([]byte, error) {
+ return []byte(d.path), nil
+}
+
+// UnmarshalText sets the path from bytes. No side effects.
+func (d *Dir) UnmarshalText(text []byte) error {
+ d.path = string(text)
+ if d.perm == 0 {
+ d.perm = os.ModePerm
+ }
+ return nil
+}
diff --git a/conf/dir_test.go b/conf/dir_test.go
new file mode 100644
index 000000000..2dd4250bc
--- /dev/null
+++ b/conf/dir_test.go
@@ -0,0 +1,127 @@
+package conf_test
+
+import (
+ "os"
+
+ "github.com/navidrome/navidrome/conf"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Dir", func() {
+ Describe("NewDir", func() {
+ It("creates a Dir with the given path without side effects", func() {
+ d := conf.NewDir("/some/path")
+ Expect(d.String()).To(Equal("/some/path"))
+ })
+ })
+
+ Describe("String", func() {
+ It("returns the raw path without creating the directory", func() {
+ d := conf.NewDir("/nonexistent/path/that/should/not/be/created")
+ Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created"))
+ })
+ })
+
+ Describe("Path", func() {
+ It("creates the directory and returns the path on first call", func() {
+ dir := GinkgoT().TempDir()
+ target := dir + "/subdir/nested"
+ d := conf.NewDir(target)
+
+ path, err := d.Path()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(path).To(Equal(target))
+ Expect(target).To(BeADirectory())
+ })
+
+ It("returns the same result on subsequent calls (sync.Once)", func() {
+ dir := GinkgoT().TempDir()
+ target := dir + "/once"
+ d := conf.NewDir(target)
+
+ path1, err1 := d.Path()
+ path2, err2 := d.Path()
+ Expect(err1).ToNot(HaveOccurred())
+ Expect(err2).ToNot(HaveOccurred())
+ Expect(path1).To(Equal(path2))
+ })
+
+ It("returns an error when directory cannot be created", func() {
+ f := GinkgoT().TempDir()
+ blocker := f + "/blocker"
+ By("creating a file that blocks directory creation")
+ Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed())
+ invalid := blocker + "/subdir"
+
+ d := conf.NewDir(invalid)
+ _, pathErr := d.Path()
+ Expect(pathErr).To(HaveOccurred())
+ })
+
+ It("returns empty path and no error for empty path", func() {
+ d := conf.NewDir("")
+ path, err := d.Path()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(path).To(BeEmpty())
+ })
+ })
+
+ Describe("MustPath", func() {
+ It("returns the path when directory is created successfully", func() {
+ dir := GinkgoT().TempDir()
+ target := dir + "/mustpath"
+ d := conf.NewDir(target)
+
+ path := d.MustPath()
+ Expect(path).To(Equal(target))
+ Expect(target).To(BeADirectory())
+ })
+
+ It("calls logFatal on error", func() {
+ var fatalMsg []any
+ restore := conf.SetLogFatal(func(args ...any) {
+ fatalMsg = args
+ panic("logFatal called")
+ })
+ DeferCleanup(restore)
+
+ f := GinkgoT().TempDir() + "/blocker"
+ Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed())
+ invalid := f + "/subdir"
+
+ d := conf.NewDir(invalid)
+ Expect(func() { d.MustPath() }).To(Panic())
+ Expect(fatalMsg).ToNot(BeEmpty())
+ })
+ })
+
+ Describe("MarshalText", func() {
+ It("returns the raw path bytes without side effects", func() {
+ d := conf.NewDir("/marshal/path")
+ b, err := d.MarshalText()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(b)).To(Equal("/marshal/path"))
+ })
+ })
+
+ Describe("UnmarshalText", func() {
+ It("sets the path from bytes without side effects", func() {
+ d := conf.NewDir("")
+ err := d.UnmarshalText([]byte("/unmarshal/path"))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(d.String()).To(Equal("/unmarshal/path"))
+ })
+
+ It("allows round-trip marshal/unmarshal", func() {
+ d1 := conf.NewDir("/round/trip")
+ b, err := d1.MarshalText()
+ Expect(err).ToNot(HaveOccurred())
+
+ var d2 conf.Dir
+ err = d2.UnmarshalText(b)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(d2.String()).To(Equal(d1.String()))
+ })
+ })
+})
diff --git a/consts/consts.go b/consts/consts.go
index bf32006d6..edd8f2b54 100644
--- a/consts/consts.go
+++ b/consts/consts.go
@@ -153,25 +153,25 @@ var (
Name: "mp3 audio",
TargetFormat: "mp3",
DefaultBitRate: 192,
- Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
+ Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
},
{
Name: "opus audio",
TargetFormat: "opus",
DefaultBitRate: 128,
- Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
+ Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
{
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
- Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
+ Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
- Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
+ Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
},
}
)
diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go
index c27964018..393cbb473 100644
--- a/core/artwork/benchmark_e2e_test.go
+++ b/core/artwork/benchmark_e2e_test.go
@@ -52,7 +52,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID
// Configure cache
conf.Server.ImageCacheSize = cacheSize
- conf.Server.CacheFolder = tmpDir
+ conf.Server.CacheFolder = conf.NewDir(tmpDir)
conf.Server.CoverArtQuality = 75
conf.Server.CoverArtPriority = "cover.*"
diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go
index 370844e34..e765e1b1b 100644
--- a/core/artwork/e2e/album_test.go
+++ b/core/artwork/e2e/album_test.go
@@ -37,15 +37,15 @@ var _ = Describe("Album artwork resolution", func() {
})
})
- // Bug 2 variant: cover.* basenames tie across album-root and per-disc folders;
- // compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder
- // files first.
+ // https://github.com/navidrome/navidrome/issues/5376
+ // cover.* basenames tie across album-root and per-disc folders;
+ // compareImageFiles must prefer shallower paths.
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
- // │ └── cover.jpg ← currently wins (bug)
+ // │ └── cover.jpg ← should not win
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg
@@ -68,15 +68,15 @@ var _ = Describe("Album artwork resolution", func() {
})
})
- // Bug 2: folder.jpg basenames tie across album-root and per-disc folders;
- // the lexicographic full-path tiebreaker in compareImageFiles ranks
- // "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg".
+ // https://github.com/navidrome/navidrome/issues/5376
+ // folder.jpg basenames tie across album-root and per-disc folders;
+ // compareImageFiles must prefer shallower paths.
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
- // │ └── folder.jpg ← currently wins (bug)
+ // │ └── folder.jpg ← should not win
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
@@ -97,15 +97,14 @@ var _ = Describe("Album artwork resolution", func() {
})
})
- // Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder
- // lookup whenever an album lives entirely under a single subfolder, so an
- // album-root cover is never considered.
+ // https://github.com/navidrome/navidrome/issues/5376
+ // Single-subfolder albums must still consider the parent folder's images.
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
// Artist/
// └── Album/
// ├── disc1/
// │ └── 01 - Track.mp3
- // └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug)
+ // └── cover.jpg ← should win (parent-folder fallback)
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
@@ -119,6 +118,32 @@ var _ = Describe("Album artwork resolution", func() {
})
})
+ // https://github.com/navidrome/navidrome/issues/5456
+ When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() {
+ // Album/ (top-level folder, Path=".")
+ // ├── CD1/
+ // │ ├── 01 - Track.mp3
+ // │ └── folder.jpg
+ // ├── CD2/
+ // │ ├── 01 - Track.mp3
+ // │ └── folder.jpg
+ // └── cover.jpg ← should win (album-root)
+ It("prefers the album-root cover.jpg", func() {
+ conf.Server.CoverArtPriority = defaultCoverPriority
+ setLayout(fstest.MapFS{
+ "Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
+ "Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
+ "Album/cover.jpg": imageFile("album-root"),
+ "Album/CD1/folder.jpg": imageFile("disc1"),
+ "Album/CD2/folder.jpg": imageFile("disc2"),
+ })
+ scan()
+
+ al := firstAlbum()
+ Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
+ })
+ })
+
When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
// Artist/
// └── Album/
diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go
index 7569cbc32..667079458 100644
--- a/core/artwork/e2e/disc_test.go
+++ b/core/artwork/e2e/disc_test.go
@@ -1,6 +1,7 @@
package artworke2e_test
import (
+ "fmt"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
@@ -255,6 +256,100 @@ var _ = Describe("Disc artwork resolution", func() {
})
})
+ // Reproduces https://github.com/navidrome/navidrome/issues/5456
+ // Deeply nested layout matching the reporter's actual structure.
+ When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() {
+ // Genre/Artist/Album/ ← album root with cover.jpg
+ // ├── cover.jpg ← album-level cover
+ // ├── Disc 01 (Subtitle)/
+ // │ ├── 01 - Track.mp3
+ // │ └── folder.jpg ← disc 1 art
+ // ├── Disc 02 (Subtitle)/
+ // │ ├── 01 - Track.mp3
+ // │ └── folder.jpg
+ // └── ... (12 discs)
+ It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
+ conf.Server.DiscArtPriority = defaultDiscPriority
+ conf.Server.CoverArtPriority = defaultCoverPriority
+ discNames := []string{
+ "Disc 01 (Birth of the Dead - The Studio Sides)",
+ "Disc 02 (Birth of the Dead - The Live Sides)",
+ "Disc 03 (The Grateful Dead)",
+ "Disc 04 (Anthem of the Sun)",
+ "Disc 05 (Aoxomoxoa)",
+ "Disc 06 (Live; Dead)",
+ "Disc 07 (Workingman's Dead)",
+ "Disc 08 (American Beauty)",
+ "Disc 09 (Grateful Dead)",
+ "Disc 10 (Europe '72)",
+ "Disc 11 (Europe '72)",
+ "Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))",
+ }
+ layout := fstest.MapFS{
+ "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"),
+ }
+ for i, name := range discNames {
+ discNum := i + 1
+ prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name)
+ layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)})
+ layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum))
+ }
+ setLayout(layout)
+ scan()
+
+ al := firstAlbum()
+
+ Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
+
+ for i := range discNames {
+ discNum := i + 1
+ discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt)
+ Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))),
+ "disc %d should use its own folder.jpg", discNum)
+ }
+ })
+ })
+
+ // https://github.com/navidrome/navidrome/issues/5456
+ // Top-level album variant — album folder at library root (Path=".").
+ When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() {
+ // Album/ (top-level, Path=".")
+ // ├── cover.jpg ← album-level cover
+ // ├── Disc 01/
+ // │ ├── 01 - Track.mp3
+ // │ └── folder.jpg ← disc 1 art
+ // ├── Disc 02/
+ // │ ├── 01 - Track.mp3
+ // │ └── folder.jpg
+ // └── Disc 03/
+ // ├── 01 - Track.mp3
+ // └── folder.jpg
+ It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
+ conf.Server.DiscArtPriority = defaultDiscPriority
+ conf.Server.CoverArtPriority = defaultCoverPriority
+ layout := fstest.MapFS{
+ "Album/cover.jpg": imageFile("album-root-cover"),
+ }
+ for i := 1; i <= 3; i++ {
+ prefix := fmt.Sprintf("Album/Disc %02d/", i)
+ layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)})
+ layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i))
+ }
+ setLayout(layout)
+ scan()
+
+ al := firstAlbum()
+
+ Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
+
+ for i := 1; i <= 3; i++ {
+ discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt)
+ Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))),
+ "disc %d should use its own folder.jpg", i)
+ }
+ })
+ })
+
When("discsubtitle is set but no image filename matches the subtitle", func() {
// Artist/
// └── Album/
diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go
index 9ce0edb8b..733e2e98c 100644
--- a/core/artwork/e2e/suite_test.go
+++ b/core/artwork/e2e/suite_test.go
@@ -63,7 +63,7 @@ func setupHarness() {
// Reuse the suite-level DB path so the singleton connection keeps working
// across specs (see suiteDBTempDir comment).
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL"
- conf.Server.DataFolder = tempDir
+ conf.Server.DataFolder = conf.NewDir(tempDir)
conf.Server.MusicFolder = fakeLibPath
conf.Server.DevExternalScanner = false
conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call
diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go
index cf5497641..73ba9b5ee 100644
--- a/core/artwork/reader_album.go
+++ b/core/artwork/reader_album.go
@@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/natural"
)
@@ -53,10 +54,9 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
lib: lib,
}
a.cacheKey.artID = artID
- if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) {
- a.cacheKey.lastUpdate = *a.updatedAt
- } else {
- a.cacheKey.lastUpdate = al.UpdatedAt
+ a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
+ if imagesUpdateAt != nil {
+ a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt)
}
return a, nil
}
@@ -131,7 +131,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
} else if err != nil {
return nil, nil, nil, err
}
- if parentFolder != nil && parentFolder.Path != "." {
+ if parentFolder != nil && parentFolder.ParentID != "" {
folders = append(folders, *parentFolder)
}
}
diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go
index b8f4f2dfa..1cf039bee 100644
--- a/core/artwork/reader_album_test.go
+++ b/core/artwork/reader_album_test.go
@@ -141,6 +141,7 @@ var _ = Describe("Album Artwork Reader", func() {
ID: "parentFolder",
Path: "Artist",
Name: "Album",
+ ParentID: "artistFolder",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg", "back.jpg"},
}
@@ -213,14 +214,14 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(repo.getCallCount).To(Equal(0))
})
- It("does not include top-level parent for multi-folder albums", func() {
- // Two album parts under the same artist folder — parent is artist-level
+ It("does not include library root parent for multi-folder albums", func() {
+ // Two album parts directly under the library root — parent is the root itself
repo.result = []model.Folder{
{
ID: "folder1",
Path: ".",
Name: "AlbumPart1",
- ParentID: "artistFolder",
+ ParentID: "rootFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
@@ -228,16 +229,17 @@ var _ = Describe("Album Artwork Reader", func() {
ID: "folder2",
Path: ".",
Name: "AlbumPart2",
- ParentID: "artistFolder",
+ ParentID: "rootFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
repo.parentResult = &model.Folder{
- ID: "artistFolder",
- Path: ".",
- Name: "Artist",
- ImageFiles: []string{"artist.jpg"},
+ ID: "rootFolder",
+ Path: "",
+ Name: ".",
+ ParentID: "",
+ ImageFiles: []string{"unrelated.jpg"},
}
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
@@ -248,6 +250,46 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(repo.getCallCount).To(Equal(1))
})
+ It("includes top-level album folder for multi-disc albums", func() {
+ // Album folder directly under library root, with disc subfolders
+ repo.result = []model.Folder{
+ {
+ ID: "folder1",
+ Path: "Album",
+ Name: "Disc1",
+ ParentID: "albumFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"folder.jpg"},
+ },
+ {
+ ID: "folder2",
+ Path: "Album",
+ Name: "Disc2",
+ ParentID: "albumFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"folder.jpg"},
+ },
+ }
+ repo.parentResult = &model.Folder{
+ ID: "albumFolder",
+ Path: ".",
+ Name: "Album",
+ ParentID: "rootFolder",
+ ImagesUpdatedAt: expectedAt,
+ ImageFiles: []string{"cover.jpg"},
+ }
+
+ _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(*imagesUpdatedAt).To(Equal(expectedAt))
+ Expect(imgFiles).To(HaveLen(3))
+ Expect(imgFiles[0]).To(Equal("Album/cover.jpg"))
+ Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg"))
+ Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg"))
+ Expect(repo.getCallCount).To(Equal(1))
+ })
+
It("does not query parent for single-folder albums that already have images", func() {
repo.result = []model.Folder{
{
@@ -283,6 +325,7 @@ var _ = Describe("Album Artwork Reader", func() {
ID: "albumFolder",
Path: "Artist",
Name: "Album",
+ ParentID: "artistFolder",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg"},
}
diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go
index e2a1f2094..50ca3a2ce 100644
--- a/core/artwork/reader_artist_test.go
+++ b/core/artwork/reader_artist_test.go
@@ -452,7 +452,7 @@ var _ = Describe("artistArtworkReader", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
- conf.Server.DataFolder = tempDir
+ conf.Server.DataFolder = conf.NewDir(tempDir)
// Create the artwork/artist directory
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())
diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go
index de0a765f0..0f648c987 100644
--- a/core/artwork/reader_disc.go
+++ b/core/artwork/reader_disc.go
@@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils"
)
type discArtworkReader struct {
@@ -105,10 +106,9 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
updatedAt: imagesUpdatedAt,
}
r.cacheKey.artID = artID
- if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) {
- r.cacheKey.lastUpdate = *r.updatedAt
- } else {
- r.cacheKey.lastUpdate = al.UpdatedAt
+ r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
+ if imagesUpdatedAt != nil {
+ r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt)
}
return r, nil
}
diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go
index 1f5bc9084..37ce1d827 100644
--- a/core/artwork/reader_radio_test.go
+++ b/core/artwork/reader_radio_test.go
@@ -21,7 +21,7 @@ var _ = Describe("radioArtworkReader", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
- conf.Server.DataFolder = tempDir
+ conf.Server.DataFolder = conf.NewDir(tempDir)
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())
diff --git a/core/auth/auth.go b/core/auth/auth.go
index a75111b35..7b3511bdf 100644
--- a/core/auth/auth.go
+++ b/core/auth/auth.go
@@ -100,7 +100,7 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
} else {
log.Error(ctx, "No admin user found!", err)
}
- u = &model.User{}
+ u = &model.User{IsAdmin: true, UserName: "admin"}
}
ctx = request.WithUsername(ctx, u.UserName)
diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go
index 80790c8d6..3225ff150 100644
--- a/core/ffmpeg/ffmpeg.go
+++ b/core/ffmpeg/ffmpeg.go
@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "slices"
"strconv"
"strings"
"sync"
@@ -394,12 +395,13 @@ func isDefaultCommand(format, command string) bool {
// including all transcoding parameters (bitrate, sample rate, channels).
func buildDynamicArgs(opts TranscodeOptions) []string {
cmdPath, _ := ffmpegCmd()
- args := []string{cmdPath, "-i", opts.FilePath}
+ args := []string{cmdPath}
if opts.Offset > 0 {
args = append(args, "-ss", strconv.Itoa(opts.Offset))
}
+ args = append(args, "-i", opts.FilePath)
args = append(args, "-map", "0:a:0")
if codec, ok := formatCodecMap[opts.Format]; ok {
@@ -491,11 +493,20 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
var args []string
for _, s := range fixCmd(cmd) {
if strings.Contains(s, "%s") {
+ if offset > 0 && !strings.Contains(cmd, "%t") {
+ // Pre-input seeking: ffmpeg seeks at the demuxer level (fast)
+ // instead of decoding all frames up to the offset (slow).
+ insertAt := len(args)
+ for i := len(args) - 1; i >= 0; i-- {
+ if args[i] == "-i" {
+ insertAt = i
+ break
+ }
+ }
+ args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset))
+ }
s = strings.ReplaceAll(s, "%s", path)
args = append(args, s)
- if offset > 0 && !strings.Contains(cmd, "%t") {
- args = append(args, "-ss", strconv.Itoa(offset))
- }
} else {
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go
index 1649015d9..562fd9100 100644
--- a/core/ffmpeg/ffmpeg_test.go
+++ b/core/ffmpeg/ffmpeg_test.go
@@ -47,15 +47,15 @@ var _ = Describe("ffmpeg", func() {
})
Context("when command has time offset param", func() {
It("creates a valid command line with offset", func() {
- args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
- Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
+ args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
+ Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})
Context("when command does not have time offset param", func() {
- It("adds time offset after the input file name", func() {
+ It("adds time offset before the input file name", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
- Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
+ Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})
})
@@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() {
Describe("isDefaultCommand", func() {
It("returns true for known default mp3 command", func() {
- Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
+ Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
})
It("returns true for known default opus command", func() {
- Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
+ Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
})
It("returns true for known default aac command", func() {
- Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
+ Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
})
It("returns true for known default flac command", func() {
- Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
+ Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
})
It("returns false for a custom command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
@@ -165,8 +165,9 @@ var _ = Describe("ffmpeg", func() {
Offset: 30,
})
Expect(args).To(Equal([]string{
- "ffmpeg", "-i", "/music/file.mp3",
+ "ffmpeg",
"-ss", "30",
+ "-i", "/music/file.mp3",
"-map", "0:a:0",
"-c:a", "libmp3lame",
"-b:a", "192k",
diff --git a/core/image_upload_test.go b/core/image_upload_test.go
index d13a04775..265f60a95 100644
--- a/core/image_upload_test.go
+++ b/core/image_upload_test.go
@@ -21,7 +21,7 @@ var _ = Describe("ImageUploadService", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
- conf.Server.DataFolder = tmpDir
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
svc = core.NewImageUploadService()
})
diff --git a/core/metrics/insights.go b/core/metrics/insights.go
index f069d3fb6..bcd0343c2 100644
--- a/core/metrics/insights.go
+++ b/core/metrics/insights.go
@@ -165,7 +165,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.OS.Containerized = consts.InContainer
// Install info
- packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
+ packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package")
packageFileData, err := os.ReadFile(packageFilename)
if err == nil {
data.OS.Package = string(packageFileData)
@@ -179,12 +179,12 @@ var staticData = sync.OnceValue(func() insights.Data {
// FS info
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
- data.FS.Data = getFSInfo(conf.Server.DataFolder)
- if conf.Server.CacheFolder != "" {
- data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
+ data.FS.Data = getFSInfo(conf.Server.DataFolder.String())
+ if conf.Server.CacheFolder.String() != "" {
+ data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String())
}
- if conf.Server.Backup.Path != "" {
- data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
+ if conf.Server.Backup.Path.String() != "" {
+ data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String())
}
// Config info
diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go
index 52d5c88d8..95d7b3e6a 100644
--- a/core/playlists/playlists_test.go
+++ b/core/playlists/playlists_test.go
@@ -307,7 +307,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
- conf.Server.DataFolder = tmpDir
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
@@ -371,7 +371,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
- conf.Server.DataFolder = tmpDir
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
// Create a real image file on disk
imgDir := filepath.Join(tmpDir, "artwork", "playlist")
diff --git a/core/stream/decider.go b/core/stream/decider.go
index cde12f0f3..d6e48497c 100644
--- a/core/stream/decider.go
+++ b/core/stream/decider.go
@@ -59,18 +59,6 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile,
decision.SourceStream = buildSourceStream(mf, probe)
src := &decision.SourceStream
- // Check for server-side player transcoding override
- if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
- clientInfo = applyServerOverride(ctx, clientInfo, &trc)
- } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
- if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
- modified := *clientInfo
- modified.MaxAudioBitrate = player.MaxBitRate
- clientInfo = &modified
- log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
- }
- }
-
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
"codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels,
"sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name)
diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go
index 8b58f3323..f74953258 100644
--- a/core/stream/decider_test.go
+++ b/core/stream/decider_test.go
@@ -1042,8 +1042,8 @@ var _ = Describe("Decider", func() {
})
})
- Context("Server-side player transcoding override", func() {
- It("forces transcoding when override targets a different format", func() {
+ Context("Server-side context is ignored by MakeDecision", func() {
+ It("ignores transcoding override in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
@@ -1051,148 +1051,21 @@ var _ = Describe("Decider", func() {
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
},
}
- // Set server override in context
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
- overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
-
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("mp3"))
- Expect(decision.TargetBitrate).To(Equal(192))
- })
-
- It("allows direct play when source matches forced format and bitrate is within cap", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- Name: "TestClient",
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256})
-
- decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- Expect(decision.CanTranscode).To(BeFalse())
- })
-
- It("transcodes when source bitrate exceeds the forced cap", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- Name: "TestClient",
- }
- overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
-
- decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("mp3"))
- Expect(decision.TargetBitrate).To(Equal(192))
- })
-
- It("uses player MaxBitRate over transcoding DefaultBitRate", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- Name: "TestClient",
- }
- overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
- overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320})
-
- decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("mp3"))
- Expect(decision.TargetBitrate).To(Equal(320))
- })
-
- It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- Name: "TestClient",
- }
- overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0})
- overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
-
- decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("mp3"))
- // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
- Expect(decision.TargetBitrate).To(Equal(160))
- })
-
- It("does not apply override when no transcoding is in context", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- Name: "TestClient",
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- // No override in context — client profiles used as-is
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
- })
-
- Context("Player MaxBitRate cap", func() {
- It("applies player MaxBitRate cap when client has no limit", func() {
+ It("ignores player MaxBitRate in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac", "mp3"}, AudioCodecs: []string{"flac", "mp3"}, Protocols: []string{ProtocolHTTP}},
- },
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
},
}
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
-
- decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- // Source bitrate 1000 > player cap 320, so direct play is not possible
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.CanTranscode).To(BeTrue())
- // Lossless→lossy should use MaxAudioBitrate (320) as target, not format default
- Expect(decision.TargetBitrate).To(Equal(320))
- })
-
- It("uses client limit when it is more restrictive than player MaxBitRate", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
- ci := &ClientInfo{
- Name: "TestClient",
- MaxAudioBitrate: 256,
- MaxTranscodingAudioBitrate: 256,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
-
- decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- // Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins
- Expect(decision.TargetBitrate).To(Equal(256))
- })
-
- It("does not cap when player MaxBitRate is 0", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- Name: "TestClient",
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
-
decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go
index d6e929ec8..9dd6179a0 100644
--- a/core/stream/legacy_client.go
+++ b/core/stream/legacy_client.go
@@ -7,12 +7,11 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
)
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
// into a ClientInfo for use with MakeDecision.
-// It does NOT read request.TranscodingFrom(ctx) — that is handled by
-// MakeDecision's applyServerOverride.
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
ci := &ClientInfo{Name: "legacy"}
@@ -65,6 +64,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
+
+ // Apply server-side player transcoding override before making the decision
+ if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
+ clientInfo = applyServerOverride(ctx, clientInfo, &trc)
+ } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
+ if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
+ modified := *clientInfo
+ modified.MaxAudioBitrate = player.MaxBitRate
+ clientInfo = &modified
+ log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
+ }
+ }
+
decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true})
if err != nil {
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)
diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go
index de1eb1339..ce7b38650 100644
--- a/core/stream/legacy_client_test.go
+++ b/core/stream/legacy_client_test.go
@@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -187,6 +188,109 @@ var _ = Describe("ResolveRequest", func() {
Expect(req.Offset).To(Equal(30))
})
+ Context("Server-side player transcoding override", func() {
+ It("forces transcoding when override targets a different format", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
+ overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(192))
+ })
+
+ It("allows direct play when source matches forced format and bitrate is within cap", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+
+ It("transcodes when source bitrate exceeds the forced cap", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(192))
+ })
+
+ It("uses player MaxBitRate over transcoding DefaultBitRate", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
+ overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(320))
+ })
+
+ It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0})
+ overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
+ Expect(req.BitRate).To(Equal(160))
+ })
+
+ It("does not apply override when no transcoding is in context", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+ })
+
+ Context("Player MaxBitRate cap", func() {
+ It("applies player MaxBitRate cap when client has no limit", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(playerCtx, mf, "mp3", 0, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(320))
+ })
+
+ It("uses client limit when it is more restrictive than player MaxBitRate", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(playerCtx, mf, "mp3", 256, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(256))
+ })
+
+ It("does not cap when player MaxBitRate is 0", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+ playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+ })
+
Context("fallback for unknown format", func() {
It("falls back to DefaultDownsamplingFormat", func() {
DeferCleanup(configtest.SetupConfig())
diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go
index 1bc21e239..1bbf868fa 100644
--- a/core/stream/media_streamer_test.go
+++ b/core/stream/media_streamer_test.go
@@ -23,7 +23,8 @@ var _ = Describe("MediaStreamer", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches")
+ cacheDir, _ := os.MkdirTemp("", "file_caches")
+ conf.Server.CacheFolder = conf.NewDir(cacheDir)
conf.Server.TranscodingCacheSize = "100MB"
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
@@ -34,7 +35,7 @@ var _ = Describe("MediaStreamer", func() {
streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache)
})
AfterEach(func() {
- _ = os.RemoveAll(conf.Server.CacheFolder)
+ _ = os.RemoveAll(conf.Server.CacheFolder.String())
})
Context("NewStream", func() {
diff --git a/db/backup.go b/db/backup.go
index a34255d7e..806bef8e2 100644
--- a/db/backup.go
+++ b/db/backup.go
@@ -27,7 +27,7 @@ const backupSuffixLayout = "2006.01.02_15.04.05"
func backupPath(t time.Time) string {
return filepath.Join(
- conf.Server.Backup.Path,
+ conf.Server.Backup.Path.MustPath(),
fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)),
)
}
@@ -117,7 +117,11 @@ func Restore(ctx context.Context, path string) error {
}
func Prune(ctx context.Context) (int, error) {
- files, err := os.ReadDir(conf.Server.Backup.Path)
+ backupDir, err := conf.Server.Backup.Path.Path()
+ if err != nil {
+ return 0, fmt.Errorf("backup directory not available: %w", err)
+ }
+ files, err := os.ReadDir(backupDir)
if err != nil {
return 0, fmt.Errorf("unable to read database backup entries: %w", err)
}
diff --git a/db/backup_test.go b/db/backup_test.go
index aec43446d..5e8f877e6 100644
--- a/db/backup_test.go
+++ b/db/backup_test.go
@@ -60,7 +60,7 @@ var _ = Describe("database backups", func() {
tempFolder, err := os.MkdirTemp("", "navidrome_backup")
Expect(err).ToNot(HaveOccurred())
- conf.Server.Backup.Path = tempFolder
+ conf.Server.Backup.Path = conf.NewDir(tempFolder)
DeferCleanup(func() {
_ = os.RemoveAll(tempFolder)
@@ -118,7 +118,7 @@ var _ = Describe("database backups", func() {
BeforeEach(func() {
tempFolder, err := os.MkdirTemp("", "navidrome_backup")
Expect(err).ToNot(HaveOccurred())
- conf.Server.Backup.Path = tempFolder
+ conf.Server.Backup.Path = conf.NewDir(tempFolder)
DeferCleanup(func() {
_ = os.RemoveAll(tempFolder)
diff --git a/db/db.go b/db/db.go
index 0945d1a00..168c12122 100644
--- a/db/db.go
+++ b/db/db.go
@@ -38,6 +38,8 @@ func Db() *sql.DB {
if Path == ":memory:" {
Path = "file::memory:?cache=shared&_foreign_keys=on"
conf.Server.DbPath = Path
+ } else {
+ conf.Server.DataFolder.MustPath()
}
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
db, err := sql.Open(Driver, Path)
diff --git a/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go
new file mode 100644
index 000000000..c16583aa0
--- /dev/null
+++ b/db/migrations/20260513173954_move_ss_before_input.go
@@ -0,0 +1,55 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upMoveSsBeforeInput, downMoveSsBeforeInput)
+}
+
+// ssSeekPairs maps old commands (output seeking) to new commands (input seeking).
+// Index 0 = old (after -i), index 1 = new (before -i).
+var ssSeekPairs = [][2]string{
+ {
+ "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
+ "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
+ },
+ {
+ "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
+ "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
+ },
+ {
+ "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
+ "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
+ },
+ {
+ "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
+ "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
+ },
+ {
+ "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
+ "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
+ },
+}
+
+func upMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error {
+ for _, p := range ssSeekPairs {
+ if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func downMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error {
+ for _, p := range ssSeekPairs {
+ if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/go.mod b/go.mod
index 36218ba6b..937cffbd5 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/navidrome/navidrome
go 1.26
// Fork to implement raw tags support
-replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a
+replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a
require (
github.com/Masterminds/squirrel v1.5.4
@@ -25,6 +25,7 @@ require (
github.com/go-chi/httprate v0.15.0
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 v0.6.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0
@@ -34,7 +35,7 @@ require (
github.com/jellydator/ttlcache/v3 v3.4.0
github.com/kardianos/service v1.2.4
github.com/kr/pretty v0.3.1
- github.com/lestrrat-go/jwx/v3 v3.1.0
+ github.com/lestrrat-go/jwx/v3 v3.1.1
github.com/mattn/go-sqlite3 v1.14.44
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
@@ -53,17 +54,17 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
- github.com/tetratelabs/wazero v1.11.0
+ github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
- golang.org/x/image v0.39.0
- golang.org/x/net v0.53.0
+ golang.org/x/image v0.40.0
+ golang.org/x/net v0.54.0
golang.org/x/sync v0.20.0
- golang.org/x/sys v0.43.0
- golang.org/x/term v0.42.0
- golang.org/x/text v0.36.0
+ golang.org/x/sys v0.44.0
+ golang.org/x/term v0.43.0
+ golang.org/x/text v0.37.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -81,19 +82,18 @@ require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
- github.com/fsnotify/fsnotify v1.10.0 // indirect
+ github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
- github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
+ github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
- github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c // indirect
+ github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -133,10 +133,10 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/crypto v0.50.0 // indirect
- golang.org/x/mod v0.35.0 // indirect
- golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
- golang.org/x/tools v0.44.0 // indirect
+ golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/mod v0.36.0 // indirect
+ golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect
+ golang.org/x/tools v0.45.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.2 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
diff --git a/go.sum b/go.sum
index 90e0ec040..0c550e47d 100644
--- a/go.sum
+++ b/go.sum
@@ -32,8 +32,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw=
-github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
+github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw=
+github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a/go.mod h1:+k5CamBu88xgydgNGJjugYVeafoCCswoGjpw5w5CvD4=
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=
@@ -63,8 +63,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M=
-github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg=
github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
@@ -106,8 +106,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
-github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
-github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
+github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
+github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -125,8 +125,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
-github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c h1:A1enk+iN8X/J1M/eN4U4NFGQToI51gCvRxEXYrfmqNs=
-github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
+github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g=
+github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
@@ -167,8 +167,8 @@ 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.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
-github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw=
-github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
+github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
+github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
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=
@@ -279,8 +279,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
-github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
-github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
+github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 h1:6GN/lazdqr69FIzz1U6c4TF/ppE2dInMR4GzU9QKxjg=
+github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633/go.mod h1:3ghOSSWYnzX0zd/3Ns4ni2tKxcXDE9/QgkwuH1PW3Rs=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -316,17 +316,17 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
-golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
-golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
-golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
+golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
-golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
+golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -338,8 +338,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
-golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -364,11 +364,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
-golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
-golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4=
-golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
+golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
+golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -377,8 +377,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
-golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -389,8 +389,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
-golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -400,8 +400,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
-golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
+golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
+golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
diff --git a/model/artist_test.go b/model/artist_test.go
index 5a24504eb..db897d3d5 100644
--- a/model/artist_test.go
+++ b/model/artist_test.go
@@ -14,7 +14,7 @@ var _ = Describe("Artist", func() {
Describe("UploadedImagePath", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- conf.Server.DataFolder = "/data"
+ conf.Server.DataFolder = conf.NewDir("/data")
})
It("returns empty string when no image uploaded", func() {
diff --git a/model/image.go b/model/image.go
index 68d8ae64c..30307fcea 100644
--- a/model/image.go
+++ b/model/image.go
@@ -13,5 +13,5 @@ func UploadedImagePath(entityType, filename string) string {
if filename == "" {
return ""
}
- return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename)
+ return filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, entityType, filename)
}
diff --git a/model/radio_test.go b/model/radio_test.go
index dc421454e..860331f17 100644
--- a/model/radio_test.go
+++ b/model/radio_test.go
@@ -26,7 +26,7 @@ var _ = Describe("Radio", func() {
Describe("UploadedImagePath", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- conf.Server.DataFolder = "/data"
+ conf.Server.DataFolder = conf.NewDir("/data")
})
It("returns empty string when no image uploaded", func() {
diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go
index e2904466c..076a9da3b 100644
--- a/persistence/artist_repository_test.go
+++ b/persistence/artist_repository_test.go
@@ -840,7 +840,7 @@ var _ = Describe("ArtistRepository", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
- conf.Server.DataFolder = tmpDir
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
ctx := request.WithUser(GinkgoT().Context(), adminUser)
repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository)
diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go
index 151a0d03c..ed8a0e810 100644
--- a/plugins/host_artwork_test.go
+++ b/plugins/host_artwork_test.go
@@ -47,7 +47,7 @@ var _ = Describe("ArtworkService", Ordered, func() {
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
// Initialize auth (required for token generation)
diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go
index 0f55bcfda..cf3973fc4 100644
--- a/plugins/host_cache_test.go
+++ b/plugins/host_cache_test.go
@@ -343,7 +343,7 @@ var _ = Describe("CacheService Integration", Ordered, func() {
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
// Setup mock DataStore with pre-enabled plugin
diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go
index bd3368a67..b296d29fb 100644
--- a/plugins/host_config_test.go
+++ b/plugins/host_config_test.go
@@ -57,7 +57,7 @@ func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, t
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
// Setup mock DataStore
diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go
index c3f6ec734..2224b7485 100644
--- a/plugins/host_kvstore.go
+++ b/plugins/host_kvstore.go
@@ -54,7 +54,7 @@ func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePerm
}
// Create plugin data directory
- dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName)
+ dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName)
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating plugin data directory: %w", err)
}
diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go
index e5d467f79..109ae8131 100644
--- a/plugins/host_kvstore_test.go
+++ b/plugins/host_kvstore_test.go
@@ -34,7 +34,7 @@ var _ = Describe("KVStoreService", func() {
Expect(err).ToNot(HaveOccurred())
DeferCleanup(configtest.SetupConfig())
- conf.Server.DataFolder = tmpDir
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
// Create service with 1KB limit for testing
maxSize := "1KB"
@@ -705,9 +705,9 @@ var _ = Describe("KVStoreService Integration", Ordered, func() {
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
- conf.Server.DataFolder = tmpDir
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
// Setup mock DataStore with pre-enabled plugin
mockPluginRepo := tests.CreateMockPluginRepo()
diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go
index 5746a3bed..67f5f9b0f 100644
--- a/plugins/host_library_test.go
+++ b/plugins/host_library_test.go
@@ -263,7 +263,7 @@ var _ = Describe("LibraryService", Ordered, func() {
// the service registration and configuration without full plugin execution
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
// Create mock &tests.MockLibraryRepo{}
mockLibRepo := &tests.MockLibraryRepo{}
@@ -357,7 +357,7 @@ var _ = Describe("LibraryService Integration", Ordered, func() {
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
// Setup mock DataStore with pre-enabled plugin and library
diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go
index 334d9b738..ca53aed56 100644
--- a/plugins/host_scheduler_test.go
+++ b/plugins/host_scheduler_test.go
@@ -51,7 +51,7 @@ var _ = Describe("SchedulerService", Ordered, func() {
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
// Create mock scheduler and timer registry
diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go
index 607f3a64b..6f7ff4dd3 100644
--- a/plugins/host_subsonicapi_test.go
+++ b/plugins/host_subsonicapi_test.go
@@ -44,7 +44,7 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() {
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
// Setup mock router and data store
diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go
index 9f2ed85f6..eff73c822 100644
--- a/plugins/host_taskqueue.go
+++ b/plugins/host_taskqueue.go
@@ -82,7 +82,7 @@ type taskQueueServiceImpl struct {
// newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database.
func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) {
- dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName)
+ dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName)
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating plugin data directory: %w", err)
}
diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go
index c3ab8d119..8a58f1eb4 100644
--- a/plugins/host_taskqueue_test.go
+++ b/plugins/host_taskqueue_test.go
@@ -40,7 +40,7 @@ var _ = Describe("TaskQueueService", func() {
Expect(err).ToNot(HaveOccurred())
DeferCleanup(configtest.SetupConfig())
- conf.Server.DataFolder = tmpDir
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
// Create a mock manager with context
managerCtx, cancel := context.WithCancel(ctx)
@@ -853,10 +853,10 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() {
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
- conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
- conf.Server.DataFolder = tmpDir
+ conf.Server.CacheFolder = conf.NewDir(filepath.Join(tmpDir, "cache"))
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
// Setup mock DataStore with pre-enabled plugin
mockPluginRepo := tests.CreateMockPluginRepo()
diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go
index 1c0de7d03..42f6a3032 100644
--- a/plugins/host_users_test.go
+++ b/plugins/host_users_test.go
@@ -484,7 +484,7 @@ func createTestUsers(mockUserRepo *tests.MockedUserRepo) {
// setupTestUsersConfig sets up common plugin configuration
func setupTestUsersConfig(tmpDir string) {
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
}
diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go
index 83fca9898..e41cfbb82 100644
--- a/plugins/host_websocket_test.go
+++ b/plugins/host_websocket_test.go
@@ -51,7 +51,7 @@ var _ = Describe("WebSocketService", Ordered, func() {
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
// Setup mock DataStore with pre-enabled plugin
diff --git a/plugins/manager.go b/plugins/manager.go
index 0e9419bfd..67e0ee987 100644
--- a/plugins/manager.go
+++ b/plugins/manager.go
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"net/http"
- "os"
"path/filepath"
"runtime"
"sync"
@@ -124,7 +123,7 @@ func (m *Manager) Start(ctx context.Context) error {
m.ctx, m.cancel = context.WithCancel(ctx)
// Initialize wazero compilation cache for better performance
- cacheDir := filepath.Join(conf.Server.CacheFolder, "plugins")
+ cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins")
purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize)
var err error
@@ -134,17 +133,12 @@ func (m *Manager) Start(ctx context.Context) error {
return fmt.Errorf("creating wazero compilation cache: %w", err)
}
- folder := conf.Server.Plugins.Folder
- if folder == "" {
+ if conf.Server.Plugins.Folder.String() == "" {
log.Debug(ctx, "No plugins folder configured")
return nil
}
- // Create plugins folder if it doesn't exist
- if err := os.MkdirAll(folder, 0755); err != nil {
- log.Error(ctx, "Failed to create plugins folder", "folder", folder, err)
- return fmt.Errorf("creating plugins folder: %w", err)
- }
+ folder := conf.Server.Plugins.Folder.MustPath()
log.Info(ctx, "Starting plugin manager", "folder", folder)
@@ -431,7 +425,7 @@ func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON s
// This synchronizes the database with the filesystem, discovering new plugins,
// updating changed ones, and removing deleted ones.
func (m *Manager) RescanPlugins(ctx context.Context) error {
- folder := conf.Server.Plugins.Folder
+ folder := conf.Server.Plugins.Folder.String()
if folder == "" {
return fmt.Errorf("plugins folder not configured")
}
diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go
index 2e024ca37..f97069e74 100644
--- a/plugins/manager_sync.go
+++ b/plugins/manager_sync.go
@@ -138,11 +138,13 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error {
filesOnDisk := make(map[string]string) // name -> path
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), PackageExtension) {
+ log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir())
continue
}
name := strings.TrimSuffix(entry.Name(), PackageExtension)
filesOnDisk[name] = filepath.Join(folder, entry.Name())
}
+ log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk))
// Get all plugins from DB
repo := m.ds.Plugin(adminCtx)
@@ -154,6 +156,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error {
for i := range dbPlugins {
pluginsInDB[dbPlugins[i].ID] = &dbPlugins[i]
}
+ log.Debug(ctx, "Plugin sync: current DB state", "pluginsInDB", len(pluginsInDB))
now := time.Now()
diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go
index 4f266bda1..b7022b46e 100644
--- a/plugins/manager_watcher.go
+++ b/plugins/manager_watcher.go
@@ -19,7 +19,7 @@ const debounceDuration = 2 * time.Second
// startWatcher starts the file watcher for the plugins folder.
// It watches for CREATE, WRITE, and REMOVE events on .wasm files.
func (m *Manager) startWatcher() error {
- folder := conf.Server.Plugins.Folder
+ folder := conf.Server.Plugins.Folder.String()
if folder == "" {
return nil
}
@@ -146,7 +146,7 @@ func (m *Manager) processPluginEvent(pluginName string) {
delete(m.debounceTimers, pluginName)
m.debounceMu.Unlock()
- folder := conf.Server.Plugins.Folder
+ folder := conf.Server.Plugins.Folder.String()
ndpPath := filepath.Join(folder, pluginName+PackageExtension)
action := determinePluginAction(ndpPath)
diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go
index 1799ba3ce..bb081988e 100644
--- a/plugins/plugins_suite_test.go
+++ b/plugins/plugins_suite_test.go
@@ -48,7 +48,7 @@ func TestPlugins(t *testing.T) {
// Set CacheFolder globally so all tests (including those using
// configtest.SetupConfig) inherit it without needing to set it manually.
- conf.Server.CacheFolder = sharedCacheDir
+ conf.Server.CacheFolder = conf.NewDir(sharedCacheDir)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
@@ -126,7 +126,7 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
- conf.Server.Plugins.Folder = tmpDir
+ conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
// Setup mock DataStore with pre-enabled plugins
diff --git a/resources/embed.go b/resources/embed.go
index 0386e6f79..040bb5d84 100644
--- a/resources/embed.go
+++ b/resources/embed.go
@@ -16,6 +16,6 @@ var embedFS embed.FS
func FS() fs.FS {
return merge.FS{
Base: embedFS,
- Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")),
+ Overlay: os.DirFS(path.Join(conf.Server.DataFolder.String(), "resources")),
}
}
diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json
index 93951a311..92b4af3d0 100644
--- a/resources/i18n/zh-Hant.json
+++ b/resources/i18n/zh-Hant.json
@@ -180,7 +180,7 @@
"name": "名稱",
"transcodingId": "轉碼",
"maxBitRate": "最大位元率",
- "client": "客戶端",
+ "client": "用戶端",
"userName": "使用者名稱",
"lastSeen": "上次上線",
"reportRealPath": "回報實際路徑",
@@ -333,7 +333,7 @@
}
},
"plugin": {
- "name": "插件 |||| 插件",
+ "name": "外掛 |||| 外掛",
"fields": {
"id": "ID",
"name": "名稱",
@@ -359,7 +359,7 @@
},
"sections": {
"status": "狀態",
- "info": "插件資訊",
+ "info": "外掛資訊",
"configuration": "設定",
"manifest": "資訊清單",
"usersPermission": "使用者權限",
@@ -379,29 +379,29 @@
"rescan": "重新掃描"
},
"notifications": {
- "enabled": "插件已啟用",
- "disabled": "插件已停用",
- "updated": "插件已更新",
- "error": "更新插件時發生錯誤"
+ "enabled": "外掛已啟用",
+ "disabled": "外掛已停用",
+ "updated": "外掛已更新",
+ "error": "更新外掛時發生錯誤"
},
"validation": {
"invalidJson": "設定必須是有效的 JSON"
},
"messages": {
- "configHelp": "使用鍵值對設定插件。若插件無需設定則留空。",
+ "configHelp": "使用鍵值對設定外掛。若外掛無需設定則留空。",
"clickPermissions": "點擊權限以查看詳細資訊",
"noConfig": "無設定",
- "allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。",
+ "allUsersHelp": "啟用後,外掛將可存取所有使用者,包含未來建立的使用者。",
"noUsers": "未選擇使用者",
"permissionReason": "原因",
- "usersRequired": "此插件需要存取使用者資訊。請選擇插件可存取的使用者,或啟用「允許所有使用者」。",
- "allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。",
+ "usersRequired": "此外掛需要存取使用者資訊。請選擇外掛可存取的使用者,或啟用「允許所有使用者」。",
+ "allLibrariesHelp": "啟用後,外掛將可存取所有媒體庫,包含未來建立的媒體庫。",
"noLibraries": "未選擇媒體庫",
- "librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。",
+ "librariesRequired": "此外掛需要存取媒體庫資訊。請選擇外掛可存取的媒體庫,或啟用「允許所有媒體庫」。",
"requiredHosts": "必要的 Hosts",
"configValidationError": "設定驗證失敗:",
- "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。",
- "allowWriteAccessHelp": "啟用後,插件可以修改媒體庫目錄中的檔案。 預設情況下,插件具有唯讀權限。"
+ "schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。",
+ "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。"
},
"placeholders": {
"configKey": "鍵",
@@ -452,7 +452,7 @@
"delete": "刪除",
"edit": "編輯",
"export": "匯出",
- "list": "列表",
+ "list": "清單",
"refresh": "重新整理",
"remove_filter": "清除此條件",
"remove": "移除",
@@ -497,9 +497,9 @@
"upload_single": "拖曳單個圖片上傳或點擊選擇一個"
},
"references": {
- "all_missing": "未找到參考數據",
- "many_missing": "至少有一條參考數據不再可用",
- "single_missing": "關聯的參考數據不再可用"
+ "all_missing": "未找到參考資料",
+ "many_missing": "至少有一條參考資料不再可用",
+ "single_missing": "關聯的參考資料不再可用"
},
"password": {
"toggle_visible": "隱藏密碼",
@@ -514,7 +514,7 @@
"delete_content": "您確定要刪除該項目?",
"delete_title": "刪除 %{name} #%{id}",
"details": "詳細資訊",
- "error": "發生客戶端錯誤,您的請求無法完成",
+ "error": "發生用戶端錯誤,您的請求無法完成",
"invalid_form": "提交內容無效,請檢查錯誤",
"loading": "正在載入頁面,請稍候",
"no": "否",
@@ -564,19 +564,19 @@
"delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?",
"notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知",
"notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
- "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄",
- "lastfmLinkFailure": "無法連接 Last.fm",
- "lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄",
- "lastfmUnlinkFailure": "無法取消與 Last.fm 的連接",
+ "lastfmLinkSuccess": "已成功連結 Last.fm 並開啟音樂記錄",
+ "lastfmLinkFailure": "無法連結 Last.fm",
+ "lastfmUnlinkSuccess": "已取消與 Last.fm 的連結並停用音樂記錄",
+ "lastfmUnlinkFailure": "無法取消與 Last.fm 的連結",
"openIn": {
"lastfm": "在 Last.fm 中開啟",
"musicbrainz": "在 MusicBrainz 中開啟"
},
"lastfmLink": "查看更多…",
- "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄",
- "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}",
- "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄",
- "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接",
+ "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連結 ListenBrainz 並開啟音樂記錄",
+ "listenBrainzLinkFailure": "無法連結 ListenBrainz:%{error}",
+ "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連結並停用音樂記錄",
+ "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連結",
"downloadOriginalFormat": "下載原始格式",
"shareOriginalFormat": "分享原始格式",
"shareDialogTitle": "分享 %{resource} '%{name}'",
@@ -718,4 +718,4 @@
"empty": "無播放內容",
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
}
-}
\ No newline at end of file
+}
diff --git a/scanner/external.go b/scanner/external.go
index 29ca90be6..393a9278c 100644
--- a/scanner/external.go
+++ b/scanner/external.go
@@ -45,8 +45,8 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod
"scan",
"--nobanner", "--subprocess",
"--configfile", conf.Server.ConfigFile,
- "--datafolder", conf.Server.DataFolder,
- "--cachefolder", conf.Server.CacheFolder,
+ "--datafolder", conf.Server.DataFolder.String(),
+ "--cachefolder", conf.Server.CacheFolder.String(),
}
// Add targets if provided
diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go
index f726343f2..d52743966 100644
--- a/scanner/phase_4_playlists.go
+++ b/scanner/phase_4_playlists.go
@@ -50,7 +50,7 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error {
return nil
}
u, _ := request.UserFrom(p.ctx)
- if !u.IsAdmin {
+ if !u.IsAdmin || u.ID == "" {
log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+
"Please create an admin user first, and then update the playlists for them to be imported")
return nil
diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go
index 6041cd013..ae3d6208c 100644
--- a/server/e2e/subsonic_transcode_test.go
+++ b/server/e2e/subsonic_transcode_test.go
@@ -396,68 +396,30 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
})
})
- Describe("player MaxBitRate cap", func() {
- It("forces transcode when source bitrate exceeds player MaxBitRate", func() {
+ Describe("player MaxBitRate cap is ignored", func() {
+ It("allows direct play even when source bitrate exceeds player MaxBitRate", func() {
setPlayerMaxBitRate(320) // 320 kbps cap
- // FLAC is 900kbps, client has no bitrate limit but player cap is 320
+ // FLAC is 900kbps, player cap is 320, but getTranscodeDecision
+ // ignores server-side overrides — client profiles are used as-is
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
- Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
- Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
- Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
- Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
- // Target bitrate should be capped at player's 320kbps = 320000 bps
- Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
- })
-
- It("does not affect direct play when source bitrate is under player MaxBitRate", func() {
- setPlayerMaxBitRate(500) // 500 kbps cap
-
- // MP3 is 320kbps, under the 500kbps player cap → direct play
- resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
- Expect(resp.Status).To(Equal(responses.StatusOK))
- Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
})
- It("uses client limit when more restrictive than player MaxBitRate", func() {
- setPlayerMaxBitRate(500) // 500 kbps player cap
-
- // Client caps at 320kbps (bitrateCapClient), which is more restrictive than 500
- // FLAC is 900kbps → exceeds both limits → transcode
- resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
- Expect(resp.Status).To(Equal(responses.StatusOK))
- Expect(resp.TranscodeDecision).ToNot(BeNil())
- Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
- Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
- // Client limit (320kbps) is more restrictive → 320000 bps
- Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
- })
-
- It("uses player MaxBitRate when more restrictive than client limit", func() {
+ It("uses only client limit, not player MaxBitRate", func() {
setPlayerMaxBitRate(192) // 192 kbps player cap
// Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192
- // FLAC is 900kbps → transcode at 192kbps
+ // but getTranscodeDecision ignores player cap → client limit (320kbps) applies
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
- // Player limit (192kbps) is more restrictive → 192000 bps
- Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
- })
-
- It("has no effect when player MaxBitRate is 0", func() {
- setPlayerMaxBitRate(0) // No player cap
-
- // FLAC with flac+mp3 client → direct play (no bitrate constraint)
- resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
- Expect(resp.Status).To(Equal(responses.StatusOK))
- Expect(resp.TranscodeDecision).ToNot(BeNil())
- Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
+ // Only client limit (320kbps) applies → 320000 bps
+ Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
})
})
@@ -513,56 +475,37 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
})
})
- Describe("player MaxBitRate + client limits combined", func() {
- It("player MaxBitRate injects maxAudioBitrate, format default used for transcode target", func() {
+ Describe("player MaxBitRate is ignored by getTranscodeDecision", func() {
+ It("does not inject maxAudioBitrate from player cap", func() {
setPlayerMaxBitRate(320)
// opusTranscodeClient has no client bitrate limits
- // Player cap injects maxAudioBitrate=320
- // FLAC (900kbps) → exceeds 320 → transcode to opus
- // Lossless→lossy: maxTranscodingAudioBitrate=0, so falls back to maxAudioBitrate=320
+ // Player cap is 320, but getTranscodeDecision ignores it
+ // FLAC (900kbps) → can't direct play → transcode to opus using format default
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
- // maxAudioBitrate=320 used as fallback → 320000 bps
- Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
+ // Bitrate should be opus format default (128kbps), not player cap (320kbps)
+ Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000)))
})
- It("player MaxBitRate + client maxTranscodingAudioBitrate work together", func() {
+ It("uses only client maxTranscodingAudioBitrate, ignoring player cap", func() {
setPlayerMaxBitRate(320)
- // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps), no maxAudioBitrate
- // Player cap injects maxAudioBitrate=320
- // FLAC (900kbps) → exceeds 320 → transcode to mp3
- // Lossless→lossy: maxTranscodingAudioBitrate=192 takes priority
+ // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps)
+ // Player cap is 320, but getTranscodeDecision ignores it
+ // Only client maxTranscodingAudioBitrate=192 applies
resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
- // maxTranscodingAudioBitrate=192 is preferred → 192000 bps
+ // maxTranscodingAudioBitrate=192 → 192000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
})
-
- It("streams with correct bitrate after player MaxBitRate-triggered transcode", func() {
- setPlayerMaxBitRate(128)
-
- // Get decision: FLAC (900kbps) with player cap 128 → transcode
- resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
- Expect(resp.Status).To(Equal(responses.StatusOK))
- Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
- token := resp.TranscodeDecision.TranscodeParams
- Expect(token).ToNot(BeEmpty())
-
- // Stream using the token
- w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
- Expect(w.Code).To(Equal(http.StatusOK))
- Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
- Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
- })
})
})
diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go
index 02626a4ee..cfecfa663 100644
--- a/server/nativeapi/config.go
+++ b/server/nativeapi/config.go
@@ -97,7 +97,7 @@ func getConfig(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Marshal the actual configuration struct to preserve original field names
- configBytes, err := json.Marshal(*conf.Server)
+ configBytes, err := json.Marshal(conf.Server)
if err != nil {
log.Error(ctx, "Error marshaling config", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
diff --git a/server/public/public.go b/server/public/public.go
index 5e3407c19..18867e1c4 100644
--- a/server/public/public.go
+++ b/server/public/public.go
@@ -5,14 +5,12 @@ import (
"path"
"github.com/go-chi/chi/v5"
- "github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/publicurl"
"github.com/navidrome/navidrome/core/stream"
- "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/ui"
@@ -43,13 +41,8 @@ func (pub *Router) routes() http.Handler {
r.Group(func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Group(func(r chi.Router) {
- if conf.Server.DevArtworkMaxRequests > 0 {
- log.Debug("Throttling public images endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
- "backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
- conf.Server.DevArtworkThrottleBacklogTimeout)
- r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
- conf.Server.DevArtworkThrottleBacklogTimeout))
- }
+ r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
+ conf.Server.DevArtworkThrottleBacklogTimeout))
r.HandleFunc("/img/{id}", pub.handleImages)
})
if conf.Server.EnableSharing {
diff --git a/server/subsonic/api.go b/server/subsonic/api.go
index 0bbfcb83e..1ca364449 100644
--- a/server/subsonic/api.go
+++ b/server/subsonic/api.go
@@ -9,7 +9,6 @@ import (
"regexp"
"github.com/go-chi/chi/v5"
- "github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
@@ -190,14 +189,8 @@ func (api *Router) routes() http.Handler {
hr(r, "getTranscodeStream", api.GetTranscodeStream)
})
r.Group(func(r chi.Router) {
- // configure request throttling
- if conf.Server.DevArtworkMaxRequests > 0 {
- log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
- "backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
- conf.Server.DevArtworkThrottleBacklogTimeout)
- r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
- conf.Server.DevArtworkThrottleBacklogTimeout))
- }
+ r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
+ conf.Server.DevArtworkThrottleBacklogTimeout))
hr(r, "getCoverArt", api.GetCoverArt)
})
r.Group(func(r chi.Router) {
diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go
index 03fa4806a..6cafe85be 100644
--- a/server/subsonic/helpers.go
+++ b/server/subsonic/helpers.go
@@ -266,6 +266,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op
child.BitDepth = int32(mf.BitDepth)
child.Genres = toItemGenres(mf.Genres)
child.Moods = mf.Tags.Values(model.TagMood)
+ child.Groupings = mf.Tags.Values(model.TagGrouping)
child.DisplayArtist = mf.Artist
child.Artists = artistRefs(mf.Participants[model.RoleArtist])
child.DisplayAlbumArtist = mf.AlbumArtist
@@ -386,6 +387,7 @@ func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubson
child.MusicBrainzId = al.MbzAlbumID
child.Genres = toItemGenres(al.Genres)
child.Moods = al.Tags.Values(model.TagMood)
+ child.Groupings = al.Tags.Values(model.TagGrouping)
child.DisplayArtist = al.AlbumArtist
child.Artists = artistRefs(al.Participants[model.RoleAlbumArtist])
child.DisplayAlbumArtist = al.AlbumArtist
diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go
index 589a609da..27d1edb84 100644
--- a/server/subsonic/media_retrieval_test.go
+++ b/server/subsonic/media_retrieval_test.go
@@ -78,16 +78,13 @@ var _ = Describe("MediaRetrievalController", func() {
When("client disconnects (context is cancelled)", func() {
It("should not call the service if cancelled before the call", func() {
- // Create a request
ctx, cancel := context.WithCancel(context.Background())
r := newGetRequest("id=34", "size=128", "square=true")
r = r.WithContext(ctx)
- cancel() // Cancel the context before the call
+ cancel()
- // Call the GetCoverArt method
_, err := router.GetCoverArt(w, r)
- // Expect no error and no call to the artwork service
Expect(err).ToNot(HaveOccurred())
Expect(artwork.recvId).To(Equal(""))
Expect(artwork.recvSize).To(Equal(0))
@@ -96,17 +93,14 @@ var _ = Describe("MediaRetrievalController", func() {
})
It("should not return data if cancelled during the call", func() {
- // Create a request with a context that will be cancelled
ctx, cancel := context.WithCancel(context.Background())
- defer cancel() // Ensure the context is cancelled after the test (best practices)
+ defer cancel()
r := newGetRequest("id=34", "size=128", "square=true")
r = r.WithContext(ctx)
- artwork.ctxCancelFunc = cancel // Set the cancel function to simulate cancellation in the service
+ artwork.ctxCancelFunc = cancel
- // Call the GetCoverArt method
_, err := router.GetCoverArt(w, r)
- // Expect no error and the service to have been called
Expect(err).ToNot(HaveOccurred())
Expect(artwork.recvId).To(Equal("34"))
Expect(artwork.recvSize).To(Equal(128))
@@ -344,7 +338,7 @@ func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, s
c.recvSize = size
c.recvSquare = square
if c.ctxCancelFunc != nil {
- c.ctxCancelFunc() // Simulate context cancellation
+ c.ctxCancelFunc()
return nil, time.Time{}, context.Canceled
}
return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil
@@ -363,9 +357,7 @@ func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles,
return data, nil
}
- // Hardcoded support for lyrics sorting
result := slices.Clone(data)
- // Sort by presence of lyrics, then by updated_at. Respect the order specified in opts.
slices.SortFunc(result, func(a, b model.MediaFile) int {
diff := cmp.Or(
cmp.Compare(a.Lyrics, b.Lyrics),
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON
index 8491a577b..9d9ae2195 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON
+++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON
@@ -56,7 +56,10 @@
"displayAlbumArtist": "Display album artist",
"contributors": [],
"displayComposer": "",
- "explicitStatus": "explicit"
+ "explicitStatus": "explicit",
+ "groupings": [
+ "Soundtrack"
+ ]
}
]
}
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML
index 5d9e83f96..d39fe2e7d 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML
+++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML
@@ -9,6 +9,7 @@
+ Soundtrack
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON
index 07678407a..a07914344 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON
+++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON
@@ -165,7 +165,11 @@
}
],
"displayComposer": "composer 1 \u0026 composer 2",
- "explicitStatus": "clean"
+ "explicitStatus": "clean",
+ "groupings": [
+ "Soundtrack",
+ "Live"
+ ]
},
{
"id": "2",
@@ -210,7 +214,8 @@
"displayAlbumArtist": "",
"contributors": [],
"displayComposer": "",
- "explicitStatus": ""
+ "explicitStatus": "",
+ "groupings": []
}
]
}
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML
index f7b23cb4e..6c96391bc 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML
+++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML
@@ -32,6 +32,8 @@
+ Soundtrack
+ Live
diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON
index d20a6d48c..4c0ea6c68 100644
--- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON
+++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON
@@ -110,7 +110,11 @@
}
],
"displayComposer": "composer 1 \u0026 composer 2",
- "explicitStatus": "clean"
+ "explicitStatus": "clean",
+ "groupings": [
+ "Soundtrack",
+ "Live"
+ ]
},
{
"id": "",
@@ -141,7 +145,8 @@
"displayAlbumArtist": "",
"contributors": [],
"displayComposer": "",
- "explicitStatus": ""
+ "explicitStatus": "",
+ "groupings": []
}
],
"id": "1",
diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML
index 1d307b0b9..ddceb67d4 100644
--- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML
+++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML
@@ -24,6 +24,8 @@
+ Soundtrack
+ Live
diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON
index 25284295e..9a9ab1ff6 100644
--- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON
+++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON
@@ -28,7 +28,8 @@
"displayAlbumArtist": "",
"contributors": [],
"displayComposer": "",
- "explicitStatus": ""
+ "explicitStatus": "",
+ "groupings": []
}
],
"id": "",
diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go
index b0b2b8752..4165669df 100644
--- a/server/subsonic/responses/responses.go
+++ b/server/subsonic/responses/responses.go
@@ -189,6 +189,7 @@ type OpenSubsonicChild struct {
Contributors Array[Contributor] `xml:"contributors,omitempty" json:"contributors"`
DisplayComposer string `xml:"displayComposer,attr,omitempty" json:"displayComposer"`
ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"`
+ Groupings Array[string] `xml:"groupings,omitempty" json:"groupings"`
}
type Songs struct {
diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go
index ee98a3daa..0b72fab19 100644
--- a/server/subsonic/responses/responses_test.go
+++ b/server/subsonic/responses/responses_test.go
@@ -224,6 +224,7 @@ var _ = Describe("Responses", func() {
Isrc: []string{"ISRC-1", "ISRC-2"},
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
Moods: []string{"happy", "sad"},
+ Groupings: []string{"Soundtrack", "Live"},
ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)},
DisplayArtist: "artist 1 & artist 2",
Artists: []ArtistID3Ref{
@@ -320,6 +321,7 @@ var _ = Describe("Responses", func() {
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song",
Isrc: []string{"ISRC-1"},
Moods: []string{"happy", "sad"},
+ Groupings: []string{"Soundtrack", "Live"},
ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)},
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
DisplayArtist: "artist1 & artist2",
@@ -424,6 +426,7 @@ var _ = Describe("Responses", func() {
ItemGenre{Name: "Genre 2"},
},
Moods: []string{"mood1", "mood2"},
+ Groupings: []string{"Soundtrack"},
DisplayArtist: "Display artist",
Artists: Array[ArtistID3Ref]{
ArtistID3Ref{Id: "artist-1", Name: "Artist 1"},
diff --git a/server/throttle_backlog.go b/server/throttle_backlog.go
new file mode 100644
index 000000000..c3672fd1e
--- /dev/null
+++ b/server/throttle_backlog.go
@@ -0,0 +1,150 @@
+package server
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/log"
+)
+
+var (
+ ErrThrottleCapacityExceeded = errors.New("throttle: capacity exceeded")
+ ErrThrottleTimeout = errors.New("throttle: backlog timeout")
+)
+
+type requestThrottle struct {
+ tokens chan struct{}
+ backlogTokens chan struct{}
+ backlogTimeout time.Duration
+}
+
+// ThrottleBacklog creates a Chi-compatible middleware that limits concurrent
+// request processing. Unlike Chi's ThrottleBacklog, it buffers the handler's
+// response while holding the token, releases it, then flushes the buffer to
+// the client with a write deadline. This prevents slow clients from holding
+// throttle capacity.
+//
+// Because it buffers the entire response in memory, this middleware should only
+// be used for endpoints that return small responses (e.g., artwork images). Do
+// not use it for audio streaming or download endpoints.
+func ThrottleBacklog(limit, backlogLimit int, backlogTimeout time.Duration) func(http.Handler) http.Handler {
+ if limit <= 0 {
+ return func(next http.Handler) http.Handler { return next }
+ }
+ if !conf.Server.DevArtworkThrottleBuffered {
+ return middleware.ThrottleBacklog(limit, backlogLimit, backlogTimeout)
+ }
+ t := &requestThrottle{
+ tokens: make(chan struct{}, limit),
+ backlogTokens: make(chan struct{}, limit+backlogLimit),
+ backlogTimeout: backlogTimeout,
+ }
+ for range limit {
+ t.tokens <- struct{}{}
+ }
+ for range limit + backlogLimit {
+ t.backlogTokens <- struct{}{}
+ }
+ return t.handler
+}
+
+func (t *requestThrottle) handler(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ release, err := t.acquire(ctx)
+ if err != nil {
+ switch {
+ case errors.Is(err, ErrThrottleCapacityExceeded):
+ log.Warn(ctx, "Request throttle capacity exceeded", "path", r.URL.Path)
+ case errors.Is(err, ErrThrottleTimeout):
+ log.Warn(ctx, "Request throttle backlog timeout", "path", r.URL.Path)
+ }
+ http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
+ return
+ }
+
+ buf := &bufferedResponseWriter{header: make(http.Header)}
+ func() {
+ defer release()
+ next.ServeHTTP(buf, r)
+ }()
+
+ for k, v := range buf.header {
+ w.Header()[k] = v
+ }
+ if buf.code > 0 {
+ w.WriteHeader(buf.code)
+ }
+ if _, err := w.Write(buf.body.Bytes()); err != nil {
+ log.Warn(ctx, "Error writing throttled response", err)
+ }
+ })
+}
+
+func (t *requestThrottle) acquire(ctx context.Context) (release func(), err error) {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-t.backlogTokens:
+ default:
+ return nil, ErrThrottleCapacityExceeded
+ }
+
+ select {
+ case <-t.tokens:
+ return t.releaseFunc(), nil
+ default:
+ }
+
+ timer := time.NewTimer(t.backlogTimeout)
+ select {
+ case <-timer.C:
+ t.backlogTokens <- struct{}{}
+ return nil, ErrThrottleTimeout
+ case <-ctx.Done():
+ timer.Stop()
+ t.backlogTokens <- struct{}{}
+ return nil, ctx.Err()
+ case <-t.tokens:
+ timer.Stop()
+ return t.releaseFunc(), nil
+ }
+}
+
+func (t *requestThrottle) releaseFunc() func() {
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ t.tokens <- struct{}{}
+ t.backlogTokens <- struct{}{}
+ })
+ }
+}
+
+type bufferedResponseWriter struct {
+ header http.Header
+ body bytes.Buffer
+ code int
+}
+
+func (w *bufferedResponseWriter) Header() http.Header {
+ return w.header
+}
+
+func (w *bufferedResponseWriter) Write(b []byte) (int, error) {
+ return w.body.Write(b)
+}
+
+func (w *bufferedResponseWriter) WriteHeader(code int) {
+ if w.code != 0 {
+ return
+ }
+ w.code = code
+}
diff --git a/server/throttle_backlog_test.go b/server/throttle_backlog_test.go
new file mode 100644
index 000000000..4d8567db5
--- /dev/null
+++ b/server/throttle_backlog_test.go
@@ -0,0 +1,266 @@
+package server
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("ThrottleBacklog", func() {
+ It("is a passthrough when limit is 0", func() {
+ m := ThrottleBacklog(0, 10, time.Second)
+ r := chi.NewRouter()
+ r.Use(m)
+ r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("ok"))
+ })
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/test", nil)
+ r.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ Expect(w.Body.String()).To(Equal("ok"))
+ })
+
+ It("returns 429 when capacity is exceeded", func() {
+ _, secondStatus := runTwoRequests(ThrottleBacklog(1, 0, time.Second))
+ Expect(secondStatus).To(Equal(http.StatusTooManyRequests))
+ })
+
+ It("returns 429 when backlog times out", func() {
+ _, secondStatus := runTwoRequests(ThrottleBacklog(1, 1, 50*time.Millisecond))
+ Expect(secondStatus).To(Equal(http.StatusTooManyRequests))
+ })
+
+ It("releases capacity when the handler panics", func() {
+ m := ThrottleBacklog(1, 0, time.Second)
+ r := chi.NewRouter()
+ r.Use(middleware.Recoverer)
+ r.Use(m)
+ r.Get("/panic", func(w http.ResponseWriter, r *http.Request) {
+ panic("boom")
+ })
+ r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("ok"))
+ })
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/panic", nil)
+ r.ServeHTTP(w, req)
+ Expect(w.Code).To(Equal(http.StatusInternalServerError))
+
+ w = httptest.NewRecorder()
+ req, _ = http.NewRequest("GET", "/test", nil)
+ r.ServeHTTP(w, req)
+ Expect(w.Code).To(Equal(http.StatusOK))
+ Expect(w.Body.String()).To(Equal("ok"))
+ })
+
+ It("preserves response headers and status code", func() {
+ m := ThrottleBacklog(2, 0, time.Second)
+ r := chi.NewRouter()
+ r.Use(m)
+ r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.Header().Set("Cache-Control", "public")
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte("body"))
+ })
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/test", nil)
+ r.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusCreated))
+ Expect(w.Header().Get("Content-Type")).To(Equal("image/jpeg"))
+ Expect(w.Header().Get("Cache-Control")).To(Equal("public"))
+ Expect(w.Body.String()).To(Equal("body"))
+ })
+
+ It("uses the first response status code", func() {
+ m := ThrottleBacklog(2, 0, time.Second)
+ r := chi.NewRouter()
+ r.Use(m)
+ r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ w.WriteHeader(http.StatusAccepted)
+ _, _ = w.Write([]byte("body"))
+ })
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/test", nil)
+ r.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusCreated))
+ Expect(w.Body.String()).To(Equal("body"))
+ })
+
+ It("never exceeds the concurrency limit", func() {
+ const limit = 3
+ const goroutines = 20
+ m := ThrottleBacklog(limit, goroutines, 5*time.Second)
+
+ var concurrent atomic.Int32
+ var maxConcurrent atomic.Int32
+
+ r := chi.NewRouter()
+ r.Use(m)
+ r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
+ cur := concurrent.Add(1)
+ for {
+ old := maxConcurrent.Load()
+ if cur <= old || maxConcurrent.CompareAndSwap(old, cur) {
+ break
+ }
+ }
+ time.Sleep(5 * time.Millisecond)
+ concurrent.Add(-1)
+ _, _ = w.Write([]byte("ok"))
+ })
+
+ var wg sync.WaitGroup
+ for range goroutines {
+ wg.Go(func() {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/test", nil)
+ r.ServeHTTP(w, req)
+ })
+ }
+
+ wg.Wait()
+ Expect(maxConcurrent.Load()).To(BeNumerically("<=", limit))
+ })
+
+ // Regression: with only 1 token, a slow client blocking during response
+ // writing must NOT prevent other requests from being served. Chi's original
+ // ThrottleBacklog holds the token for the entire handler lifecycle including
+ // io.Copy, causing starvation. The buffered implementation releases it first.
+ Context("when a client is slow to read the response", func() {
+ slowClientTest := func(m func(http.Handler) http.Handler) (*chi.Mux, chan struct{}, chan struct{}) {
+ handlerReached := make(chan struct{}, 1)
+ router := chi.NewRouter()
+ router.Use(m)
+ router.Get("/test", func(w http.ResponseWriter, r *http.Request) {
+ select {
+ case handlerReached <- struct{}{}:
+ default:
+ }
+ _, _ = io.Copy(w, strings.NewReader("image data"))
+ })
+
+ unblocked := make(chan struct{})
+ slow := newSlowTestWriter(unblocked)
+
+ reqDone := make(chan struct{})
+ go func() {
+ defer close(reqDone)
+ req, _ := http.NewRequest("GET", "/test", nil)
+ router.ServeHTTP(slow, req)
+ }()
+ <-handlerReached
+
+ return router, unblocked, reqDone
+ }
+
+ It("does not starve concurrent requests with buffered middleware", func() {
+ router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond))
+
+ Eventually(func() int {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/test", nil)
+ router.ServeHTTP(w, req)
+ return w.Code
+ }, 2*time.Second, 10*time.Millisecond).Should(Equal(http.StatusOK))
+
+ close(unblocked)
+ Eventually(reqDone, 2*time.Second).Should(BeClosed())
+ })
+
+ It("starves concurrent requests with Chi's original middleware", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DevArtworkThrottleBuffered = false
+
+ router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond))
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/test", nil)
+ router.ServeHTTP(w, req)
+ Expect(w.Code).To(Equal(http.StatusTooManyRequests))
+
+ close(unblocked)
+ Eventually(reqDone, 2*time.Second).Should(BeClosed())
+ })
+ })
+})
+
+// runTwoRequests sends two concurrent requests through a throttled router. The
+// first request holds the token until the second has been dispatched.
+func runTwoRequests(m func(http.Handler) http.Handler) (firstStatus, secondStatus int) {
+ held := make(chan struct{}, 1)
+ release := make(chan struct{})
+ r := chi.NewRouter()
+ r.Use(m)
+ r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
+ select {
+ case held <- struct{}{}:
+ default:
+ }
+ <-release
+ _, _ = w.Write([]byte("ok"))
+ })
+
+ done := make(chan int)
+ go func() {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/test", nil)
+ r.ServeHTTP(w, req)
+ done <- w.Code
+ }()
+ <-held
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/test", nil)
+ r.ServeHTTP(w, req)
+ secondStatus = w.Code
+
+ close(release)
+ firstStatus = <-done
+ return firstStatus, secondStatus
+}
+
+// slowTestWriter implements http.ResponseWriter without embedding
+// httptest.ResponseRecorder. This is necessary because ResponseRecorder
+// promotes io.ReaderFrom, which io.Copy prefers over Write — bypassing
+// our blocking Write and defeating the slow-client simulation.
+type slowTestWriter struct {
+ header http.Header
+ body bytes.Buffer
+ code int
+ unblocked chan struct{}
+}
+
+func newSlowTestWriter(unblocked chan struct{}) *slowTestWriter {
+ return &slowTestWriter{header: make(http.Header), unblocked: unblocked}
+}
+
+func (w *slowTestWriter) Header() http.Header { return w.header }
+
+func (w *slowTestWriter) WriteHeader(code int) { w.code = code }
+
+func (w *slowTestWriter) Write(p []byte) (int, error) {
+ <-w.unblocked
+ return w.body.Write(p)
+}
diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go
index 796e84111..641daca8a 100644
--- a/tests/mock_transcoding_repo.go
+++ b/tests/mock_transcoding_repo.go
@@ -19,9 +19,9 @@ func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, e
case "opus":
return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil
case "flac":
- return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil
+ return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil
case "aac":
- return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil
+ return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil
default:
return nil, model.ErrNotFound
}
diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx
index 67af85b81..346e1ceae 100644
--- a/ui/src/plugin/PluginList.jsx
+++ b/ui/src/plugin/PluginList.jsx
@@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback } from 'react'
import {
Button,
Datagrid,
+ Empty,
TextField,
TopToolbar,
useNotify,
@@ -10,7 +11,13 @@ import {
useTranslate,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
-import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core'
+import {
+ useMediaQuery,
+ Tooltip,
+ Chip,
+ Typography,
+ Box,
+} from '@material-ui/core'
import { MdError, MdRefresh } from 'react-icons/md'
import { List, DateField, SimpleList, useResourceRefresh } from '../common'
import { httpClient } from '../dataProvider'
@@ -72,8 +79,7 @@ const ManifestField = ({ source }) => {
return {manifest[source] || '-'}
}
-const PluginListActions = () => {
- const translate = useTranslate()
+const RescanButton = () => {
const notify = useNotify()
const refresh = useRefresh()
const [loading, setLoading] = useState(false)
@@ -92,20 +98,37 @@ const PluginListActions = () => {
})
}, [notify, refresh])
+ return (
+
+ )
+}
+
+const PluginListActions = () => {
return (
-
+
)
}
+const PluginEmpty = () => {
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
const PluginList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const translate = useTranslate()
@@ -118,6 +141,7 @@ const PluginList = (props) => {
exporter={false}
bulkActionButtons={false}
actions={}
+ empty={}
>
{isXsmall ? (
{
TopToolbar: ({ children }) => (
{children}
),
+ Empty: () => No resources
,
Datagrid: ({ children }) => {children}
,
TextField: ({ source }) => ,
}
@@ -42,9 +49,10 @@ vi.mock('react-admin', async () => {
// Mock common components
vi.mock('../common', async () => {
return {
- List: ({ children, actions, ...props }) => (
+ List: ({ children, actions, empty, ...props }) => (
{actions}
+ {empty &&
{empty}
}
{children}
),
@@ -94,14 +102,16 @@ describe('PluginList', () => {
expect(screen.getByTestId('datagrid')).toBeInTheDocument()
})
- it('renders the rescan button', () => {
+ it('renders the rescan button in the toolbar', () => {
render()
- expect(screen.getByTestId('rescan-button')).toBeInTheDocument()
+ const toolbar = screen.getByTestId('top-toolbar')
+ expect(within(toolbar).getByTestId('rescan-button')).toBeInTheDocument()
})
it('calls rescan endpoint when rescan button is clicked', async () => {
render()
- const rescanButton = screen.getByTestId('rescan-button')
+ const toolbar = screen.getByTestId('top-toolbar')
+ const rescanButton = within(toolbar).getByTestId('rescan-button')
fireEvent.click(rescanButton)
@@ -114,7 +124,8 @@ describe('PluginList', () => {
it('calls refresh after successful rescan', async () => {
render()
- const rescanButton = screen.getByTestId('rescan-button')
+ const toolbar = screen.getByTestId('top-toolbar')
+ const rescanButton = within(toolbar).getByTestId('rescan-button')
fireEvent.click(rescanButton)
@@ -127,7 +138,8 @@ describe('PluginList', () => {
mockHttpClient.mockRejectedValue(new Error('Network error'))
render()
- const rescanButton = screen.getByTestId('rescan-button')
+ const toolbar = screen.getByTestId('top-toolbar')
+ const rescanButton = within(toolbar).getByTestId('rescan-button')
fireEvent.click(rescanButton)
@@ -137,4 +149,25 @@ describe('PluginList', () => {
})
})
})
+
+ it('renders a rescan button in the empty state', () => {
+ render()
+ const emptyState = screen.getByTestId('empty-state')
+ expect(emptyState).toBeInTheDocument()
+ expect(within(emptyState).getByTestId('rescan-button')).toBeInTheDocument()
+ })
+
+ it('empty state rescan button triggers rescan', async () => {
+ render()
+ const emptyState = screen.getByTestId('empty-state')
+ const rescanButton = within(emptyState).getByTestId('rescan-button')
+
+ fireEvent.click(rescanButton)
+
+ await waitFor(() => {
+ expect(mockHttpClient).toHaveBeenCalledWith('/api/plugin/rescan', {
+ method: 'POST',
+ })
+ })
+ })
})
diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go
index 1fe448f84..e3fc08eda 100644
--- a/utils/cache/benchmark_test.go
+++ b/utils/cache/benchmark_test.go
@@ -28,7 +28,7 @@ func setupBenchCache(b *testing.B, cacheSize string, getReader ReadFunc) (*fileC
b.Fatal(err)
}
b.Cleanup(configtest.SetupConfig())
- conf.Server.CacheFolder = tmpDir
+ conf.Server.CacheFolder = conf.NewDir(tmpDir)
fc := NewFileCache("bench", cacheSize, "bench", 0, getReader).(*fileCache)
diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go
index 5edc533f8..9788926d5 100644
--- a/utils/cache/file_caches.go
+++ b/utils/cache/file_caches.go
@@ -262,7 +262,7 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach
lru := NewFileHaunter(name, maxItems, size, consts.DefaultCacheCleanUpInterval)
h := fscache.NewLRUHaunterStrategy(lru)
- cacheFolder = filepath.Join(conf.Server.CacheFolder, cacheFolder)
+ cacheFolder = filepath.Join(conf.Server.CacheFolder.MustPath(), cacheFolder)
var fs *spreadFS
log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size))
diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go
index 72f4463d1..9a9a9444f 100644
--- a/utils/cache/file_caches_test.go
+++ b/utils/cache/file_caches_test.go
@@ -28,14 +28,14 @@ var _ = Describe("File Caches", func() {
configtest.SetupConfig()
_ = os.RemoveAll(tmpDir)
})
- conf.Server.CacheFolder = tmpDir
+ conf.Server.CacheFolder = conf.NewDir(tmpDir)
})
Describe("NewFileCache", func() {
It("creates the cache folder", func() {
Expect(callNewFileCache("test", "1k", "test", 0, nil)).ToNot(BeNil())
- _, err := os.Stat(filepath.Join(conf.Server.CacheFolder, "test"))
+ _, err := os.Stat(filepath.Join(conf.Server.CacheFolder.String(), "test"))
Expect(os.IsNotExist(err)).To(BeFalse())
})