diff --git a/.gitignore b/.gitignore
index 73475a53a..fc8eaac69 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,5 +37,6 @@ AGENTS.md
*.wasm
*.ndp
openspec/
+.agents
go.work*
-.worktrees/
\ No newline at end of file
+.worktrees/
diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go
index 31150c673..d51f65dd9 100644
--- a/adapters/deezer/client.go
+++ b/adapters/deezer/client.go
@@ -1,7 +1,7 @@
package deezer
import (
- bytes "bytes"
+ "bytes"
"context"
"encoding/json"
"errors"
diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go
index 4a93f5b83..e7dd18ac1 100644
--- a/adapters/gotaglib/end_to_end_test.go
+++ b/adapters/gotaglib/end_to_end_test.go
@@ -8,7 +8,6 @@ import (
"github.com/djherbis/times"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -91,8 +90,7 @@ var _ = Describe("Extractor", func() {
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
- mf := metadata.ToMediaFile(1, "folderID")
- return &mf
+ return new(metadata.ToMediaFile(1, "folderID"))
}
BeforeEach(func() {
@@ -109,7 +107,7 @@ var _ = Describe("Extractor", func() {
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
},
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
- Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
+ Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)),
)
})
@@ -120,8 +118,8 @@ var _ = Describe("Extractor", func() {
DisplayTitle: "",
Lang: code,
Line: []model.Line{
- {Start: gg.P(int64(0)), Value: "This is"},
- {Start: gg.P(int64(2500)), Value: secondLine},
+ {Start: new(int64(0)), Value: "This is"},
+ {Start: new(int64(2500)), Value: secondLine},
},
Offset: nil,
Synced: true,
diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go
index 162ae9037..499863e28 100644
--- a/adapters/lastfm/auth_router.go
+++ b/adapters/lastfm/auth_router.go
@@ -77,6 +77,13 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
return
}
resp["status"] = key != ""
+ linkToken, err := createLinkToken(u.ID)
+ if err != nil {
+ log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err)
+ _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ resp["linkToken"] = linkToken
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
}
@@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
return
}
- uid, err := p.String("uid")
+ linkToken, err := p.String("uid")
if err != nil {
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
return
}
+ uid, err := verifyLinkToken(linkToken)
+ if err != nil {
+ log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err)
+ _ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token")
+ return
+ }
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
// automatically contain any user info
diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go
new file mode 100644
index 000000000..4cbbd4298
--- /dev/null
+++ b/adapters/lastfm/auth_router_test.go
@@ -0,0 +1,218 @@
+package lastfm
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "time"
+
+ "github.com/navidrome/navidrome/core/agents"
+ "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"
+)
+
+var _ = Describe("auth_router", func() {
+ var (
+ ds *tests.MockDataStore
+ userProps *tests.MockedUserPropsRepo
+ httpClient *tests.FakeHttpClient
+ router *Router
+ )
+
+ const (
+ victimID = "victim-user-id"
+ attackerID = "attacker-user-id"
+ )
+
+ BeforeEach(func() {
+ userProps = &tests.MockedUserPropsRepo{}
+ ds = &tests.MockDataStore{
+ MockedProperty: &tests.MockedPropertyRepo{},
+ MockedUserProps: userProps,
+ }
+ auth.Init(ds)
+
+ httpClient = &tests.FakeHttpClient{}
+ router = &Router{
+ ds: ds,
+ apiKey: "API_KEY",
+ secret: "SECRET",
+ sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
+ }
+ router.client = newClient(router.apiKey, router.secret, httpClient)
+ router.Handler = router.routes()
+ })
+
+ storedSessionKey := func(userID string) string {
+ key, _ := userProps.Get(userID, sessionKeyProperty)
+ return key
+ }
+
+ stubGetSessionOK := func(sessionKey string) {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)),
+ StatusCode: 200,
+ }
+ }
+
+ Describe("getLinkStatus", func() {
+ It("includes a signed linkToken for the authenticated user", func() {
+ req := httptest.NewRequest(http.MethodGet, "/link", nil)
+ ctx := request.WithUser(req.Context(), model.User{ID: victimID})
+ req = req.WithContext(ctx)
+ rec := httptest.NewRecorder()
+
+ router.getLinkStatus(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ var body map[string]any
+ Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed())
+ Expect(body["apiKey"]).To(Equal("API_KEY"))
+ Expect(body["status"]).To(Equal(false))
+ token, ok := body["linkToken"].(string)
+ Expect(ok).To(BeTrue())
+ Expect(token).ToNot(BeEmpty())
+
+ verified, err := verifyLinkToken(token)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(verified).To(Equal(victimID))
+ })
+ })
+
+ Describe("callback", func() {
+ It("stores the session key under the user encoded in the signed token", func() {
+ stubGetSessionOK("LEGIT_SESSION")
+ linkToken, err := createLinkToken(victimID)
+ Expect(err).ToNot(HaveOccurred())
+
+ req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil)
+ rec := httptest.NewRecorder()
+ router.callback(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION"))
+ })
+
+ It("rejects a raw (unsigned) uid value", func() {
+ req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil)
+ rec := httptest.NewRecorder()
+ router.callback(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusBadRequest))
+ Expect(storedSessionKey(victimID)).To(BeEmpty())
+ Expect(httpClient.SavedRequest).To(BeNil())
+ })
+
+ It("rejects an expired link token", func() {
+ expiredToken, err := auth.EncodeToken(map[string]any{
+ "uid": victimID,
+ "scope": linkTokenScope,
+ "exp": time.Now().Add(-1 * time.Minute).UTC().Unix(),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil)
+ rec := httptest.NewRecorder()
+ router.callback(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusBadRequest))
+ Expect(storedSessionKey(victimID)).To(BeEmpty())
+ Expect(httpClient.SavedRequest).To(BeNil())
+ })
+
+ It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() {
+ sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"})
+ Expect(err).ToNot(HaveOccurred())
+
+ req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil)
+ rec := httptest.NewRecorder()
+ router.callback(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusBadRequest))
+ Expect(storedSessionKey(attackerID)).To(BeEmpty())
+ Expect(httpClient.SavedRequest).To(BeNil())
+ })
+
+ It("writes only under the user encoded in the token, regardless of query manipulation", func() {
+ // An attacker holds a legitimate link token for their own account.
+ // They attempt to call the callback hoping to overwrite the victim's
+ // session key — but the handler must derive the user ID from the
+ // signed token, not from any other input.
+ stubGetSessionOK("ATTACKER_SESSION")
+ attackerToken, err := createLinkToken(attackerID)
+ Expect(err).ToNot(HaveOccurred())
+
+ req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil)
+ rec := httptest.NewRecorder()
+ router.callback(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION"))
+ Expect(storedSessionKey(victimID)).To(BeEmpty())
+ })
+
+ It("returns 400 when uid is missing", func() {
+ req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil)
+ rec := httptest.NewRecorder()
+ router.callback(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusBadRequest))
+ })
+
+ It("returns 400 when token is missing", func() {
+ linkToken, err := createLinkToken(victimID)
+ Expect(err).ToNot(HaveOccurred())
+
+ req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil)
+ rec := httptest.NewRecorder()
+ router.callback(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusBadRequest))
+ })
+ })
+
+ Describe("link token helpers", func() {
+ It("round-trips a freshly issued token", func() {
+ token, err := createLinkToken(victimID)
+ Expect(err).ToNot(HaveOccurred())
+
+ uid, err := verifyLinkToken(token)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(uid).To(Equal(victimID))
+ })
+
+ It("rejects garbage", func() {
+ _, err := verifyLinkToken("not-a-jwt")
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("rejects a token whose scope claim is wrong", func() {
+ wrongScopeToken, err := auth.EncodeToken(map[string]any{
+ "uid": victimID,
+ "scope": "some-other-scope",
+ "exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = verifyLinkToken(wrongScopeToken)
+ Expect(err).To(MatchError("invalid link token scope"))
+ })
+
+ It("rejects a scoped token that has no expiration", func() {
+ nonExpiringToken, err := auth.EncodeToken(map[string]any{
+ "uid": victimID,
+ "scope": linkTokenScope,
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = verifyLinkToken(nonExpiringToken)
+ Expect(err).To(MatchError("link token missing expiration"))
+ })
+ })
+})
diff --git a/adapters/lastfm/link_token.go b/adapters/lastfm/link_token.go
new file mode 100644
index 000000000..fd8ceb3c9
--- /dev/null
+++ b/adapters/lastfm/link_token.go
@@ -0,0 +1,50 @@
+package lastfm
+
+import (
+ "errors"
+ "time"
+
+ "github.com/navidrome/navidrome/core/auth"
+)
+
+const (
+ linkTokenScope = "lastfm-link"
+ linkTokenTTL = 5 * time.Minute
+)
+
+// createLinkToken issues a signed token binding the Last.fm callback to the
+// user who initiated the OAuth flow. It travels back through Last.fm via the
+// `cb` URL in place of the previously-trusted raw `uid` query parameter.
+func createLinkToken(userID string) (string, error) {
+ claims := map[string]any{
+ "uid": userID,
+ "scope": linkTokenScope,
+ "exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
+ }
+ return auth.EncodeToken(claims)
+}
+
+// verifyLinkToken validates a signed link token and returns the encoded user ID.
+// It enforces both the signature/expiry (via the underlying JWT verifier) and a
+// dedicated scope claim, preventing tokens minted for other purposes (e.g. a
+// regular session JWT) from being accepted here.
+func verifyLinkToken(tokenStr string) (string, error) {
+ token, err := auth.DecodeAndVerifyToken(tokenStr)
+ if err != nil {
+ return "", err
+ }
+ // jwtauth treats a token without `exp` as non-expiring; require it
+ // explicitly so an accidental regression cannot mint permanent tokens.
+ if exp, ok := token.Expiration(); !ok || exp.IsZero() {
+ return "", errors.New("link token missing expiration")
+ }
+ var scope string
+ if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope {
+ return "", errors.New("invalid link token scope")
+ }
+ var uid string
+ if err := token.Get("uid", &uid); err != nil || uid == "" {
+ return "", errors.New("invalid link token user ID")
+ }
+ return uid, nil
+}
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 887e8c68a..219b7b0bd 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
@@ -45,7 +47,6 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
- EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableM3UExternalAlbumArt bool
@@ -111,6 +112,7 @@ type configOptions struct {
PID pidOptions `json:",omitzero"`
Inspect inspectOptions `json:",omitzero"`
Subsonic subsonicOptions `json:",omitzero"`
+ Transcoding transcodingOptions `json:",omitzero"`
LastFM lastfmOptions `json:",omitzero"`
Deezer deezerOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
@@ -163,6 +165,12 @@ type scannerOptions struct {
PurgeMissing string // Values: "never", "always", "full"
}
+type transcodingOptions struct {
+ MaxConcurrent int
+ MaxConcurrentPerUser int
+ EnableCancellation bool
+}
+
type subsonicOptions struct {
AppendSubtitle bool
AppendAlbumVersion bool
@@ -229,7 +237,7 @@ type jukeboxOptions struct {
type backupOptions struct {
Count int
- Path string
+ Path Dir
Schedule string
}
@@ -247,7 +255,7 @@ type inspectOptions struct {
type pluginsOptions struct {
Enabled bool
- Folder string
+ Folder Dir
CacheSize string
AutoReload bool
LogLevel string
@@ -287,6 +295,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()
@@ -306,8 +330,15 @@ func Load(noConfigDump bool) {
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
+ mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
- 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 +348,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()))
@@ -445,6 +456,7 @@ func Load(noConfigDump bool) {
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
+ logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
// Removed options
logRemovedOptions("Spotify.ID", "Spotify.Secret")
@@ -636,7 +648,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
}
@@ -733,7 +745,6 @@ func setViperDefaults() {
viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
viper.SetDefault("enabletranscodingconfig", false)
- viper.SetDefault("enabletranscodingcancellation", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
@@ -818,6 +829,9 @@ func setViperDefaults() {
viper.SetDefault("subsonic.enableaveragerating", true)
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("subsonic.minimalclients", "SubMusic")
+ viper.SetDefault("transcoding.maxconcurrent", 0)
+ viper.SetDefault("transcoding.maxconcurrentperuser", 0)
+ viper.SetDefault("transcoding.enablecancellation", false)
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
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..abbe72b77
--- /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 // uses a value receiver so Dir values satisfy GoStringer
+ 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/archiver.go b/core/archiver.go
index 96cc2c31e..5d1c090cd 100644
--- a/core/archiver.go
+++ b/core/archiver.go
@@ -3,6 +3,7 @@ package core
import (
"archive/zip"
"context"
+ "errors"
"fmt"
"io"
"os"
@@ -60,7 +61,15 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
for _, mf := range album {
file := a.albumFilename(mf, format, isMultiDisc)
- _ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
+ if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
+ // Stop iterating: continuing would just rack up more
+ // rejections from the limiter. Close finalises whatever
+ // tracks were already written; the rejected one is not
+ // present in the archive (addFileToZip aborts before
+ // writing its entry header).
+ _ = z.Close()
+ return addErr
+ }
}
}
err = z.Close()
@@ -120,7 +129,12 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
zippedMfs := make(model.MediaFiles, len(mfs))
for idx, mf := range mfs {
file := a.playlistFilename(mf, format, idx)
- _ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
+ if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
+ // Abort the whole archive: continuing would silently emit
+ // empty zip entries since the headers are already written.
+ _ = z.Close()
+ return addErr
+ }
mf.Path = file
zippedMfs[idx] = mf
}
@@ -162,6 +176,27 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
path := mf.AbsolutePath()
+
+ // Open the source before writing the zip entry header so a rejection
+ // (limiter, missing file, etc.) does not leave an empty entry in the
+ // archive.
+ var r io.ReadCloser
+ var err error
+ if format != "raw" && format != "" {
+ r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
+ } else {
+ r, err = os.Open(path)
+ }
+ if err != nil {
+ log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
+ return err
+ }
+ defer func() {
+ if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
+ log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
+ }
+ }()
+
w, err := z.CreateHeader(&zip.FileHeader{
Name: filename,
Modified: mf.UpdatedAt,
@@ -172,23 +207,6 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
return err
}
- var r io.ReadCloser
- if format != "raw" && format != "" {
- r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
- } else {
- r, err = os.Open(path)
- }
- if err != nil {
- log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
- return err
- }
-
- defer func() {
- if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
- log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
- }
- }()
-
_, err = io.Copy(w, r)
if err != nil {
log.Error(ctx, "Error zipping file", "file", path, err)
diff --git a/core/archiver_test.go b/core/archiver_test.go
index 4f7aed278..f432139d8 100644
--- a/core/archiver_test.go
+++ b/core/archiver_test.go
@@ -89,6 +89,32 @@ var _ = Describe("Archiver", func() {
})
})
+ Context("when the transcode limiter rejects a file", func() {
+ It("aborts the archive instead of continuing with empty entries", func() {
+ mfs := model.MediaFiles{
+ {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
+ {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
+ }
+
+ mfRepo := &mockMediaFileRepository{}
+ mfRepo.On("GetAll", []model.QueryOptions{{
+ Filters: squirrel.Eq{"album_id": "1"},
+ Sort: "album",
+ }}).Return(mfs, nil)
+ ds.On("MediaFile", mock.Anything).Return(mfRepo)
+
+ ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
+ Return(nil, stream.ErrTooManyTranscodes).Once()
+
+ out := new(bytes.Buffer)
+ err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
+ Expect(err).To(MatchError(stream.ErrTooManyTranscodes))
+ // NewStream should only have been called once: the loop must bail
+ // out on the rejection instead of trying every remaining track.
+ ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1)
+ })
+ })
+
Context("ZipShare", func() {
It("zips a share correctly", func() {
mfs := model.MediaFiles{
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/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_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_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_test.go b/core/auth/auth_test.go
index 761dd205c..3a3585e53 100644
--- a/core/auth/auth_test.go
+++ b/core/auth/auth_test.go
@@ -21,8 +21,7 @@ func TestAuth(t *testing.T) {
}
const (
- testJWTSecret = "not so secret"
- oneDay = 24 * time.Hour
+ oneDay = 24 * time.Hour
)
var _ = BeforeSuite(func() {
diff --git a/core/external/provider.go b/core/external/provider.go
index 4f3295cc7..74dab4972 100644
--- a/core/external/provider.go
+++ b/core/external/provider.go
@@ -153,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
return album, err
}
- album.ExternalInfoUpdatedAt = P(time.Now())
+ album.ExternalInfoUpdatedAt = new(time.Now())
album.ExternalUrl = info.URL
if info.Description != "" {
@@ -269,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
return artist, ctx.Err()
}
- artist.ExternalInfoUpdatedAt = P(time.Now())
+ artist.ExternalInfoUpdatedAt = new(time.Now())
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
if err != nil {
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go
index 37d3fd81a..79612d651 100644
--- a/core/external/provider_artistimage_test.go
+++ b/core/external/provider_artistimage_test.go
@@ -272,12 +272,11 @@ var _ = Describe("Provider - ArtistImage", func() {
It("returns cached URL and does not call agent when info is not expired", func() {
// Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt
- recentTime := time.Now().Add(-1 * time.Minute)
cachedArtist := &model.Artist{
ID: "artist-cached",
Name: "Cached Artist",
LargeImageUrl: "http://example.com/cached-large.jpg",
- ExternalInfoUpdatedAt: &recentTime,
+ ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)),
}
mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe()
expectedURL, _ := url.Parse("http://example.com/cached-large.jpg")
@@ -304,12 +303,11 @@ var _ = Describe("Provider - ArtistImage", func() {
It("returns stale URL and enqueues refresh when info is expired", func() {
// Arrange
conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond
- expiredTime := time.Now().Add(-1 * time.Hour)
staleArtist := &model.Artist{
ID: "artist-expired",
Name: "Expired Artist",
LargeImageUrl: "http://example.com/expired-large.jpg",
- ExternalInfoUpdatedAt: &expiredTime,
+ ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)),
}
mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe()
expectedURL, _ := url.Parse("http://example.com/expired-large.jpg")
diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go
index 3dd8a587a..21824c93f 100644
--- a/core/external/provider_updatealbuminfo_test.go
+++ b/core/external/provider_updatealbuminfo_test.go
@@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@@ -90,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ExternalUrl: "http://cached.com/album",
Description: "Cached Desc",
LargeImageUrl: "http://cached.com/large.jpg",
- ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
+ ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
}
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
@@ -113,7 +112,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ExternalUrl: "http://expired.com/album",
Description: "Expired Desc",
LargeImageUrl: "http://expired.com/large.jpg",
- ExternalInfoUpdatedAt: gg.P(expiredTime),
+ ExternalInfoUpdatedAt: new(expiredTime),
}
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go
index cc9506d1f..d783128fb 100644
--- a/core/external/provider_updateartistinfo_test.go
+++ b/core/external/provider_updateartistinfo_test.go
@@ -13,7 +13,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@@ -137,7 +136,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ExternalUrl: "http://cached.url",
Biography: "Cached Bio",
LargeImageUrl: "http://cached_large.jpg",
- ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
+ ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{
{ID: "ar-similar-present", Name: "Similar Present"},
{ID: "ar-similar-absent", Name: "Similar Absent"},
@@ -174,7 +173,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
originalArtist := &model.Artist{
ID: "ar-expired",
Name: "Expired Artist",
- ExternalInfoUpdatedAt: gg.P(expiredTime),
+ ExternalInfoUpdatedAt: new(expiredTime),
SimilarArtists: model.Artists{
{ID: "ar-exp-similar", Name: "Expired Similar"},
},
@@ -205,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
originalArtist := &model.Artist{
ID: "ar-similar-test",
Name: "Similar Test Artist",
- ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
+ ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{
{ID: "ar-sim-present", Name: "Similar Present"},
{ID: "", Name: "Similar Absent Raw"},
diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go
index 80790c8d6..a3f6cd7d2 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"
@@ -325,8 +326,7 @@ func (j *ffCmd) start(ctx context.Context) error {
func (j *ffCmd) wait() {
if err := j.cmd.Wait(); err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
+ if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())
if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" {
errMsg += ": " + stderrOutput
@@ -394,12 +394,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 +492,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..2e2895738 100644
--- a/core/ffmpeg/ffmpeg_test.go
+++ b/core/ffmpeg/ffmpeg_test.go
@@ -7,7 +7,7 @@ import (
"path/filepath"
"runtime"
"strings"
- sync "sync"
+ "sync"
"testing"
"time"
@@ -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/inspect.go b/core/inspect.go
index 751cf063f..01ec33760 100644
--- a/core/inspect.go
+++ b/core/inspect.go
@@ -7,7 +7,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
- . "github.com/navidrome/navidrome/utils/gg"
)
type InspectOutput struct {
@@ -44,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
result := &InspectOutput{
File: filePath,
RawTags: tags[file].Tags,
- MappedTags: P(md.ToMediaFile(libraryId, folderId)),
+ MappedTags: new(md.ToMediaFile(libraryId, folderId)),
}
return result, nil
diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go
index 8fbaec6c5..53890718d 100644
--- a/core/lyrics/lyrics_test.go
+++ b/core/lyrics/lyrics_test.go
@@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -32,15 +31,15 @@ var _ = Describe("sources", func() {
Lang: "eng",
Line: []model.Line{
{
- Start: gg.P(int64(18800)),
+ Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
- Start: gg.P(int64(22801)),
+ Start: new(int64(22801)),
Value: "You know the rules and so do I",
},
},
- Offset: gg.P(int64(-100)),
+ Offset: new(int64(-100)),
Synced: true,
},
}
diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go
index 1e98323ca..694f589a5 100644
--- a/core/lyrics/sources_test.go
+++ b/core/lyrics/sources_test.go
@@ -5,7 +5,6 @@ import (
"encoding/json"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -74,15 +73,15 @@ var _ = Describe("sources", func() {
Lang: "eng",
Line: []model.Line{
{
- Start: gg.P(int64(18800)),
+ Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
- Start: gg.P(int64(22801)),
+ Start: new(int64(22801)),
Value: "You know the rules and so do I",
},
},
- Offset: gg.P(int64(-100)),
+ Offset: new(int64(-100)),
Synced: true,
},
}))
@@ -265,7 +264,7 @@ var _ = Describe("sources", func() {
// The critical assertion: even with BOM, synced should be true
Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
Expect(lyrics[0].Line).To(HaveLen(1))
- Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0))))
+ Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
})
@@ -280,9 +279,9 @@ var _ = Describe("sources", func() {
// UTF-16 should be properly converted to UTF-8
Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
Expect(lyrics[0].Line).To(HaveLen(2))
- Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800))))
+ Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
- Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801))))
+ Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
})
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/playback/mpv/mpv.go b/core/playback/mpv/mpv.go
index 035e18dd5..6696eca2a 100644
--- a/core/playback/mpv/mpv.go
+++ b/core/playback/mpv/mpv.go
@@ -62,8 +62,7 @@ func (j *Executor) start(ctx context.Context) error {
func (j *Executor) wait() {
if err := j.cmd.Wait(); err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
+ if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
} else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go
index 14170efd4..1038b9190 100644
--- a/core/playback/mpv/track.go
+++ b/core/playback/mpv/track.go
@@ -206,7 +206,7 @@ func (t *MpvTrack) IsPlaying() bool {
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
start := time.Now()
end := start.Add(timeout)
- var retries int = 0
+ var retries = 0
for {
fileInfo, err := os.Stat(path)
diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go
index 56c80a950..a5b8b7c02 100644
--- a/core/playlists/parse_nsp.go
+++ b/core/playlists/parse_nsp.go
@@ -59,8 +59,7 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
}
err = json.Unmarshal(input, nsp)
if err != nil {
- var syntaxErr *json.SyntaxError
- if errors.As(err, &syntaxErr) {
+ if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok {
line, col := getPositionFromOffset(input, syntaxErr.Offset)
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
}
diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go
index 52d5c88d8..f849a0a21 100644
--- a/core/playlists/playlists_test.go
+++ b/core/playlists/playlists_test.go
@@ -144,29 +144,25 @@ var _ = Describe("Playlists", func() {
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- newName := "Updated Name"
- err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
+ err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
- newName := "Updated Name"
- err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
+ err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin from updating", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
- newName := "Updated Name"
- err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
+ err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- newName := "Updated Name"
- err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
+ err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil)
Expect(err).To(Equal(model.ErrNotFound))
})
@@ -184,8 +180,7 @@ var _ = Describe("Playlists", func() {
It("allows metadata updates on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- newName := "Updated Smart"
- err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
+ err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
})
@@ -307,7 +302,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 +366,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/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go
index 90d22327a..68461b259 100644
--- a/core/playlists/rest_adapter_test.go
+++ b/core/playlists/rest_adapter_test.go
@@ -63,7 +63,6 @@ var _ = Describe("REST Adapter", func() {
It("clears server-managed fields to prevent injection via REST API", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
- now := time.Now()
pls := &model.Playlist{
Name: "Legit Playlist",
Comment: "A comment",
@@ -73,7 +72,7 @@ var _ = Describe("REST Adapter", func() {
Sync: true,
UploadedImage: "injected-image-path",
ExternalImageURL: "http://evil.example.com/ssrf",
- EvaluatedAt: &now,
+ EvaluatedAt: new(time.Now()),
}
_, err := repo.Save(pls)
Expect(err).ToNot(HaveOccurred())
diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go
index 684b887fe..5383244cd 100644
--- a/core/scrobbler/play_tracker_test.go
+++ b/core/scrobbler/play_tracker_test.go
@@ -1133,8 +1133,7 @@ func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession
if f.Error != nil {
return f.Error
}
- uid := info.UserId
- f.userID.Store(&uid)
+ f.userID.Store(new(info.UserId))
f.LastPlaybackReport.Store(&info)
return nil
}
diff --git a/core/share.go b/core/share.go
index a6d06a018..eb9b63ae9 100644
--- a/core/share.go
+++ b/core/share.go
@@ -41,7 +41,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
return nil, model.ErrExpired
}
- share.LastVisitedAt = P(time.Now())
+ share.LastVisitedAt = new(time.Now())
share.VisitCount++
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
@@ -95,7 +95,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
}
s.ID = id
if V(s.ExpiresAt).IsZero() {
- s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
+ s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration))
}
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
diff --git a/core/stream/limiter.go b/core/stream/limiter.go
new file mode 100644
index 000000000..622fe21cc
--- /dev/null
+++ b/core/stream/limiter.go
@@ -0,0 +1,135 @@
+package stream
+
+import (
+ "context"
+ "errors"
+ "io"
+ "sync"
+ "sync/atomic"
+)
+
+// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the
+// configured concurrency cap has been reached. Callers should translate this
+// into an HTTP 429 response so well-behaved clients back off and retry.
+var ErrTooManyTranscodes = errors.New("too many concurrent transcodes")
+
+// RetryAfterSeconds is the value returned in the HTTP Retry-After header when
+// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well
+// within this window, so retrying after this delay typically succeeds.
+const RetryAfterSeconds = 5
+
+// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces
+// both a global cap (to protect the host from process exhaustion) and an optional
+// per-user cap (to keep one client from starving the others). Acquire never
+// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately.
+type TranscodeLimiter interface {
+ // Acquire reserves a slot for the given user. On success it returns a release
+ // function that must be called exactly once when the transcode is done.
+ // Calling release more than once is safe and idempotent.
+ Acquire(ctx context.Context, user string) (release func(), err error)
+
+ // Enabled reports whether the limiter actually enforces any cap. Callers
+ // can use it to decide whether to bind ffmpeg's lifetime to the request
+ // context so disconnects free slots quickly, rather than letting the
+ // process drain to completion in the background.
+ Enabled() bool
+}
+
+// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is
+// independent: a value of zero or less disables that cap. When both caps are
+// disabled the limiter is a no-op.
+func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter {
+ if maxConcurrent <= 0 && maxPerUser <= 0 {
+ return noopLimiter{}
+ }
+ l := &transcodeLimiter{maxPerUser: maxPerUser}
+ if maxConcurrent > 0 {
+ l.global = make(chan struct{}, maxConcurrent)
+ }
+ if maxPerUser > 0 {
+ l.perUser = make(map[string]int)
+ }
+ return l
+}
+
+// releasingReadCloser wraps an io.ReadCloser so that closing it also releases
+// the limiter slot exactly once. release must be the function returned by
+// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too.
+type releasingReadCloser struct {
+ io.ReadCloser
+ release func()
+}
+
+func (r *releasingReadCloser) Close() error {
+ err := r.ReadCloser.Close()
+ r.release()
+ return err
+}
+
+type noopLimiter struct{}
+
+func (noopLimiter) Acquire(context.Context, string) (func(), error) {
+ return func() {}, nil
+}
+
+func (noopLimiter) Enabled() bool { return false }
+
+type transcodeLimiter struct {
+ maxPerUser int
+ global chan struct{}
+
+ mu sync.Mutex
+ perUser map[string]int
+}
+
+func (*transcodeLimiter) Enabled() bool { return true }
+
+func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) {
+ // Reserve a per-user slot first so a noisy user can't burn through
+ // global slots only to be rejected later. An empty user key means
+ // "anonymous" (e.g. public share viewers); we skip the per-user cap
+ // entirely so unrelated anonymous clients do not share a bucket.
+ perUserActive := l.maxPerUser > 0 && user != ""
+ if perUserActive {
+ l.mu.Lock()
+ if l.perUser[user] >= l.maxPerUser {
+ l.mu.Unlock()
+ return nil, ErrTooManyTranscodes
+ }
+ l.perUser[user]++
+ l.mu.Unlock()
+ }
+
+ if l.global != nil {
+ select {
+ case l.global <- struct{}{}:
+ default:
+ if perUserActive {
+ l.releasePerUser(user)
+ }
+ return nil, ErrTooManyTranscodes
+ }
+ }
+
+ var released atomic.Bool
+ return func() {
+ if !released.CompareAndSwap(false, true) {
+ return
+ }
+ if l.global != nil {
+ <-l.global
+ }
+ if perUserActive {
+ l.releasePerUser(user)
+ }
+ }, nil
+}
+
+func (l *transcodeLimiter) releasePerUser(user string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.perUser[user]--
+ if l.perUser[user] <= 0 {
+ delete(l.perUser, user)
+ }
+}
diff --git a/core/stream/limiter_test.go b/core/stream/limiter_test.go
new file mode 100644
index 000000000..d278d47c8
--- /dev/null
+++ b/core/stream/limiter_test.go
@@ -0,0 +1,186 @@
+package stream_test
+
+import (
+ "context"
+ "errors"
+ "sync"
+
+ "github.com/navidrome/navidrome/core/stream"
+ "github.com/navidrome/navidrome/log"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("TranscodeLimiter", func() {
+ ctx := log.NewContext(context.TODO())
+
+ Describe("Disabled (both caps <= 0)", func() {
+ It("never blocks and never returns ErrTooManyTranscodes", func() {
+ lim := stream.NewTranscodeLimiter(0, 0)
+ for range 100 {
+ rel, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rel).ToNot(BeNil())
+ }
+ })
+ })
+
+ Describe("Per-user cap only (no global cap)", func() {
+ It("still enforces the per-user limit when MaxConcurrent is disabled", func() {
+ lim := stream.NewTranscodeLimiter(0, 2)
+
+ rel1, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ rel2, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+
+ // Other users have their own buckets.
+ rel3, err := lim.Acquire(ctx, "bob")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel1()
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel2()
+ rel3()
+ })
+ })
+
+ Describe("Global cap", func() {
+ It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
+ lim := stream.NewTranscodeLimiter(2, 0)
+
+ rel1, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ rel2, err := lim.Acquire(ctx, "bob")
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = lim.Acquire(ctx, "carol")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+
+ rel1()
+ _, err = lim.Acquire(ctx, "carol")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel2()
+ })
+
+ It("releases a slot only once even if release is called multiple times", func() {
+ lim := stream.NewTranscodeLimiter(1, 0)
+
+ rel, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel()
+ rel()
+ rel()
+
+ // After releases, exactly one slot should be available.
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+ })
+ })
+
+ Describe("Per-user cap", func() {
+ It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() {
+ lim := stream.NewTranscodeLimiter(10, 2)
+
+ rel1, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ rel2, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+
+ // A different user is unaffected.
+ rel3, err := lim.Acquire(ctx, "bob")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel1()
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel2()
+ rel3()
+ })
+
+ It("skips the per-user cap for anonymous users (empty key)", func() {
+ // Anonymous requests (e.g. public share viewers) deliberately
+ // bypass the per-user cap so unrelated anonymous clients are not
+ // collapsed into a single shared bucket. The global cap remains
+ // the only ceiling on anonymous traffic.
+ lim := stream.NewTranscodeLimiter(10, 1)
+
+ rels := make([]func(), 0, 5)
+ for range 5 {
+ rel, err := lim.Acquire(ctx, "")
+ Expect(err).ToNot(HaveOccurred())
+ rels = append(rels, rel)
+ }
+ for _, rel := range rels {
+ rel()
+ }
+ })
+
+ It("still applies the global cap to anonymous users", func() {
+ lim := stream.NewTranscodeLimiter(2, 1)
+
+ rel1, err := lim.Acquire(ctx, "")
+ Expect(err).ToNot(HaveOccurred())
+ rel2, err := lim.Acquire(ctx, "")
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = lim.Acquire(ctx, "")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+
+ rel1()
+ rel2()
+ })
+ })
+
+ Describe("Concurrent safety", func() {
+ It("survives parallel Acquire/release with consistent counts", func() {
+ lim := stream.NewTranscodeLimiter(5, 0)
+
+ var wg sync.WaitGroup
+ var acquired int64
+ var rejected int64
+ var mu sync.Mutex
+
+ for i := range 50 {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ rel, err := lim.Acquire(ctx, "alice")
+ mu.Lock()
+ if err == nil {
+ acquired++
+ mu.Unlock()
+ rel()
+ } else {
+ rejected++
+ mu.Unlock()
+ }
+ _ = i
+ }(i)
+ }
+ wg.Wait()
+
+ Expect(acquired + rejected).To(Equal(int64(50)))
+ // After all releases, all 5 slots should be free again.
+ for range 5 {
+ _, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ }
+ _, err := lim.Acquire(ctx, "alice")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+ })
+ })
+})
diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go
index de03b4d2f..b09d9bab8 100644
--- a/core/stream/media_streamer.go
+++ b/core/stream/media_streamer.go
@@ -2,6 +2,7 @@ package stream
import (
"context"
+ "errors"
"fmt"
"io"
"mime"
@@ -28,13 +29,19 @@ type MediaStreamer interface {
type TranscodingCache cache.FileCache
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
- return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
+ return &mediaStreamer{
+ ds: ds,
+ transcoder: t,
+ cache: cache,
+ limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser),
+ }
}
type mediaStreamer struct {
ds model.DataStore
transcoder ffmpeg.FFmpeg
cache cache.FileCache
+ limiter TranscodeLimiter
}
type streamJob struct {
@@ -104,7 +111,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
- log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
+ // Rate-limit rejections are already logged at warn level by the
+ // producer; treating them as cache failures here would both
+ // double-log and mask actual cache problems.
+ if !errors.Is(err, ErrTooManyTranscodes) {
+ log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
+ }
return nil, err
}
cached = r.Cached
@@ -217,15 +229,31 @@ func NewTranscodingCache() TranscodingCache {
return nil, os.ErrInvalid
}
- // Choose the appropriate context based on EnableTranscodingCancellation configuration.
- // This is where we decide whether transcoding processes should be cancellable or not.
+ release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx))
+ if err != nil {
+ log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached",
+ "id", job.mf.ID, "user", userName(ctx),
+ "maxConcurrent", conf.Server.Transcoding.MaxConcurrent,
+ "maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser)
+ return nil, err
+ }
+
+ // Choose the context that drives the ffmpeg process.
+ //
+ // When the limiter is enabled, force the request context so a
+ // client disconnect cancels ffmpeg and frees the slot promptly.
+ // Otherwise a client could open many transcodes, disconnect
+ // immediately, and still leave the configured cap's worth of
+ // ffmpeg processes draining in the background — which is exactly
+ // the DoS the limiter is meant to prevent.
+ //
+ // When the limiter is disabled, preserve the legacy behavior
+ // governed by Transcoding.EnableCancellation so unchanged configs
+ // keep their previous observable behavior.
var transcodingCtx context.Context
- if conf.Server.EnableTranscodingCancellation {
- // Use the request context directly, allowing cancellation when client disconnects
+ if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation {
transcodingCtx = ctx
} else {
- // Use background context with request values preserved.
- // This prevents cancellation but maintains request metadata (user, client, etc.)
transcodingCtx = request.AddValues(context.Background(), ctx)
}
@@ -240,10 +268,14 @@ func NewTranscodingCache() TranscodingCache {
Offset: job.offset,
})
if err != nil {
+ release()
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
}
- return out, nil
+ // Tie the slot to the ffmpeg process: copyAndClose calls Close
+ // on this reader after io.Copy returns, which is exactly when
+ // ffmpeg has exited (either EOF or context cancellation).
+ return &releasingReadCloser{ReadCloser: out, release: release}, nil
})
}
@@ -255,3 +287,16 @@ func userName(ctx context.Context) string {
return user.UserName
}
}
+
+// limiterKey returns the per-user bucket key used by the transcode limiter.
+// For anonymous requests (e.g. public shares) it returns the empty string,
+// which signals the limiter to skip the per-user cap entirely — otherwise
+// every anonymous viewer of a public share would collide on the same key
+// and starve each other within MaxConcurrentPerUser slots. The global cap
+// still applies and remains the protection against runaway anonymous load.
+func limiterKey(ctx context.Context) string {
+ if user, ok := request.UserFrom(ctx); ok {
+ return user.UserName
+ }
+ return ""
+}
diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go
index 1bc21e239..676e8d6f8 100644
--- a/core/stream/media_streamer_test.go
+++ b/core/stream/media_streamer_test.go
@@ -2,6 +2,7 @@ package stream_test
import (
"context"
+ "errors"
"io"
"os"
@@ -10,6 +11,7 @@ import (
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"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"
@@ -23,7 +25,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 +37,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() {
@@ -60,6 +63,58 @@ var _ = Describe("MediaStreamer", func() {
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
+ It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
+ // Rebuild the streamer with a tight cap. The first request will hold the
+ // ffmpeg reader open (we don't read/close it), saturating the single slot.
+ conf.Server.Transcoding.MaxConcurrent = 1
+ conf.Server.Transcoding.MaxConcurrentPerUser = 0
+ tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache())
+
+ userCtx := request.WithUsername(ctx, "alice")
+ s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
+ Expect(err).ToNot(HaveOccurred())
+ defer s1.Close()
+
+ // Different cache key so it doesn't dedupe with the first request.
+ _, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+ })
+
+ It("releases the slot once the stream is closed", func() {
+ conf.Server.Transcoding.MaxConcurrent = 1
+ conf.Server.Transcoding.MaxConcurrentPerUser = 0
+ tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache())
+
+ userCtx := request.WithUsername(ctx, "alice")
+ s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
+ Expect(err).ToNot(HaveOccurred())
+ _, _ = io.ReadAll(s1)
+ _ = s1.Close()
+ Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
+
+ // Slot should now be free for a different transcode.
+ s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
+ Expect(err).ToNot(HaveOccurred())
+ defer s2.Close()
+ })
+
+ It("does not consume a slot for raw streams", func() {
+ conf.Server.Transcoding.MaxConcurrent = 1
+ conf.Server.Transcoding.MaxConcurrentPerUser = 0
+ tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache())
+
+ userCtx := request.WithUsername(ctx, "alice")
+ // First, saturate the single transcode slot.
+ s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
+ Expect(err).ToNot(HaveOccurred())
+ defer s1.Close()
+
+ // Raw stream must still succeed.
+ s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"})
+ Expect(err).ToNot(HaveOccurred())
+ defer s2.Close()
+ })
+
It("returns a seekable stream if the file is complete in the cache", func() {
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
Expect(err).To(BeNil())
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/db/migrations/20260520211813_add_media_file_artists_composite_index.sql b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql
new file mode 100644
index 000000000..f65050d80
--- /dev/null
+++ b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id_role
+ ON media_file_artists (media_file_id, role);
+DROP INDEX IF EXISTS media_file_artists_media_file_id;
+
+-- +goose Down
+CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id
+ ON media_file_artists (media_file_id);
+DROP INDEX IF EXISTS media_file_artists_media_file_id_role;
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/artwork_id_test.go b/model/artwork_id_test.go
index b634e7cbc..ad66f7bb5 100644
--- a/model/artwork_id_test.go
+++ b/model/artwork_id_test.go
@@ -11,8 +11,7 @@ import (
var _ = Describe("ArtworkID", func() {
Describe("NewArtworkID()", func() {
It("creates a valid parseable ArtworkID", func() {
- now := time.Now()
- id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now)
+ id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now()))
parsedId, err := model.ParseArtworkID(id.String())
Expect(err).ToNot(HaveOccurred())
Expect(parsedId.Kind).To(Equal(id.Kind))
diff --git a/model/criteria/export_test.go b/model/criteria/export_test.go
index 9f3f3922b..e2109aa1a 100644
--- a/model/criteria/export_test.go
+++ b/model/criteria/export_test.go
@@ -1,5 +1,3 @@
package criteria
-var StartOfPeriod = startOfPeriod
-
type UnmarshalConjunctionType = unmarshalConjunctionType
diff --git a/model/criteria/operators.go b/model/criteria/operators.go
index 3ddd77f8b..14a02ff4b 100644
--- a/model/criteria/operators.go
+++ b/model/criteria/operators.go
@@ -1,7 +1,5 @@
package criteria
-import "time"
-
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
type conjunction interface {
ChildPlaylistIds() []string
@@ -142,10 +140,6 @@ func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
func (nitl NotInTheLast) fields() map[string]any { return nitl }
-func startOfPeriod(numDays int64, from time.Time) string {
- return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
-}
-
type InPlaylist map[string]any
func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
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/lyrics_test.go b/model/lyrics_test.go
index 1fa82f258..f04d6e04a 100644
--- a/model/lyrics_test.go
+++ b/model/lyrics_test.go
@@ -8,14 +8,13 @@ import (
var _ = Describe("ToLyrics", func() {
It("should parse tags with spaces", func() {
- num := int64(1551)
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Lang).To(Equal("eng"))
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.DisplayArtist).To(Equal("An artist"))
Expect(lyrics.DisplayTitle).To(Equal("A title"))
- Expect(lyrics.Offset).To(Equal(&num))
+ Expect(lyrics.Offset).To(Equal(new(int64(1551))))
})
It("Should ignore bad offset", func() {
@@ -25,39 +24,36 @@ var _ = Describe("ToLyrics", func() {
})
It("should accept lines with no text and weird times", func() {
- a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60)
lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "Hi there"},
- {Start: &b, Value: ""},
- {Start: &c, Value: "Test"},
- {Start: &d, Value: "late"},
+ {Start: new(int64(0)), Value: "Hi there"},
+ {Start: new(int64(10040)), Value: ""},
+ {Start: new(int64(40000)), Value: "Test"},
+ {Start: new(int64(1000 * 60 * 60)), Value: "late"},
}))
})
It("Should support multiple timestamps per line", func() {
- a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51)
lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "Repeated"},
- {Start: &b, Value: "Repeated"},
- {Start: &c, Value: ""},
- {Start: &d, Value: ""},
+ {Start: new(int64(0)), Value: "Repeated"},
+ {Start: new(int64(10000)), Value: "Repeated"},
+ {Start: new(int64(13 * 60 * 1000)), Value: ""},
+ {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""},
}))
})
It("Should support parsing multiline string", func() {
- a, b := int64(0), int64(10*60*1000+1)
lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "This is\na multiline\n\n[:0] string"},
- {Start: &b, Value: "This is\nalso one"},
+ {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"},
+ {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"},
}))
})
@@ -71,49 +67,45 @@ var _ = Describe("ToLyrics", func() {
})
It("Allows timestamp in middle of line if also at beginning", func() {
- a, b := int64(0), int64(1000)
lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "This is [00:00:00] be a synced file"},
- {Start: &b, Value: "Line 2"},
+ {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"},
+ {Start: new(int64(1000)), Value: "Line 2"},
}))
})
It("Ignores lines in synchronized lyric prior to first timestamp", func() {
- a := int64(0)
lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "Text"},
+ {Start: new(int64(0)), Value: "Text"},
}))
})
It("Handles all possible ms cases", func() {
- a, b, c := int64(1), int64(10), int64(100)
lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "a"},
- {Start: &b, Value: "b"},
- {Start: &c, Value: "c"},
+ {Start: new(int64(1)), Value: "a"},
+ {Start: new(int64(10)), Value: "b"},
+ {Start: new(int64(100)), Value: "c"},
}))
})
It("Properly sorts repeated lyrics out of order", func() {
- a, b, c, d, e := int64(0), int64(10000), int64(40000), int64(13*60*1000), int64(1000*60*60*51)
lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "Repeated"},
- {Start: &b, Value: "Test"},
- {Start: &c, Value: "Not repeated"},
- {Start: &d, Value: "Repeated"},
- {Start: &e, Value: "Test"},
+ {Start: new(int64(0)), Value: "Repeated"},
+ {Start: new(int64(10000)), Value: "Test"},
+ {Start: new(int64(40000)), Value: "Not repeated"},
+ {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"},
+ {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"},
}))
})
diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go
index e3adf3fae..16142f526 100644
--- a/model/metadata/map_mediafile_test.go
+++ b/model/metadata/map_mediafile_test.go
@@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
- . "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -108,8 +107,8 @@ var _ = Describe("ToMediaFile", func() {
expected := model.LyricList{
{Lang: "eng", Line: []model.Line{
- {Value: "This is", Start: P(int64(0))},
- {Value: "English SYLT", Start: P(int64(2500))},
+ {Value: "This is", Start: new(int64(0))},
+ {Value: "English SYLT", Start: new(int64(2500))},
}, Synced: true},
{Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false},
}
diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go
index 71cb9c1f2..5ee802ced 100644
--- a/model/metadata/map_participants_test.go
+++ b/model/metadata/map_participants_test.go
@@ -684,6 +684,26 @@ var _ = Describe("Participants", func() {
Expect(composers[2].Name).To(Equal("The Album Artist"))
})
})
+
+ // Sibling fix to https://github.com/navidrome/navidrome/issues/5065: when
+ // multiple frames map to the same role tag (e.g. TIPL producer entries),
+ // the configured split separator must still apply to each value.
+ When("the tag has multiple values", func() {
+ It("should split each value individually", func() {
+ mf = toMediaFile(model.RawTags{
+ "COMPOSER": {"John Doe/Jane Doe", "Someone Else"},
+ })
+
+ participants := mf.Participants
+ Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
+ composers := participants[model.RoleComposer]
+ Expect(composers).To(ConsistOf(
+ HaveField("Name", "John Doe"),
+ HaveField("Name", "Jane Doe"),
+ HaveField("Name", "Someone Else"),
+ ))
+ })
+ })
})
Describe("MBID tags", func() {
diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go
index 663e306c4..350731b89 100644
--- a/model/metadata/metadata_test.go
+++ b/model/metadata/metadata_test.go
@@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -130,6 +129,21 @@ var _ = Describe("Metadata", func() {
Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"}))
})
+
+ // Regression test for https://github.com/navidrome/navidrome/issues/5065
+ //
+ // MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by
+ // TagLib's PropertyMap as a single "mood" key with multiple values. The split
+ // configuration must still apply to each value individually.
+ It("should split values from multiple frames mapping to the same tag", func() {
+ props.Tags = model.RawTags{
+ // Same shape as the bug report: two frames, comma-separated content.
+ "mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"},
+ }
+ md = metadata.New(filePath, props)
+
+ Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad"))
+ })
})
DescribeTable("Date",
@@ -274,8 +288,8 @@ var _ = Describe("Metadata", func() {
mf := createMF("replaygain_track_gain", tagValue)
Expect(mf.RGTrackGain).To(Equal(expected))
},
- Entry("0", "0", gg.P(0.0)),
- Entry("1.2dB", "1.2dB", gg.P(1.2)),
+ Entry("0", "0", new(0.0)),
+ Entry("1.2dB", "1.2dB", new(1.2)),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
Entry("NaN", "NaN", nil),
@@ -285,9 +299,9 @@ var _ = Describe("Metadata", func() {
mf := createMF("replaygain_track_peak", tagValue)
Expect(mf.RGTrackPeak).To(Equal(expected))
},
- Entry("0", "0", gg.P(0.0)),
- Entry("1.0", "1.0", gg.P(1.0)),
- Entry("0.5", "0.5", gg.P(0.5)),
+ Entry("0", "0", new(0.0)),
+ Entry("1.0", "1.0", new(1.0)),
+ Entry("0.5", "0.5", new(0.5)),
Entry("Invalid dB suffix", "0.7dB", nil),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
@@ -299,8 +313,8 @@ var _ = Describe("Metadata", func() {
Expect(mf.RGTrackGain).To(Equal(expected))
},
- Entry("0", "0", gg.P(5.0)),
- Entry("-3776", "-3776", gg.P(-9.75)),
+ Entry("0", "0", new(5.0)),
+ Entry("-3776", "-3776", new(-9.75)),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
)
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/model/tag_mappings.go b/model/tag_mappings.go
index bfe098f77..af76de741 100644
--- a/model/tag_mappings.go
+++ b/model/tag_mappings.go
@@ -34,23 +34,25 @@ type TagConf struct {
SplitRx *regexp.Regexp `yaml:"-"`
}
-// SplitTagValue splits a tag value by the split separators, but only if it has a single value.
+// SplitTagValue splits tag values by the configured split separators.
+// Each value in the input slice is individually split and trimmed.
func (c TagConf) SplitTagValue(values []string) []string {
- // If there's not exactly one value or no separators, return early.
- if len(values) != 1 || c.SplitRx == nil {
+ if c.SplitRx == nil || len(values) == 0 {
return values
}
- tag := values[0]
- // Replace all occurrences of any separator with the zero-width space.
- tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp)
+ var result []string
+ for _, tag := range values {
+ // Replace all occurrences of any separator with the zero-width space.
+ tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp)
- // Split by the zero-width space and trim each substring.
- parts := strings.Split(tag, consts.Zwsp)
- for i, part := range parts {
- parts[i] = strings.TrimSpace(part)
+ // Split by the zero-width space and trim each substring.
+ parts := strings.Split(tag, consts.Zwsp)
+ for _, part := range parts {
+ result = append(result, strings.TrimSpace(part))
+ }
}
- return parts
+ return result
}
type TagType string
diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go
new file mode 100644
index 000000000..1665d557b
--- /dev/null
+++ b/model/tag_mappings_test.go
@@ -0,0 +1,64 @@
+package model
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("TagConf", func() {
+ Describe("SplitTagValue", func() {
+ var conf TagConf
+
+ BeforeEach(func() {
+ conf = TagConf{Split: []string{";", "/", ","}}
+ conf.SplitRx = compileSplitRegex("test", conf.Split)
+ })
+
+ It("splits a single value on configured separators", func() {
+ Expect(conf.SplitTagValue([]string{"Rock/Pop;Punk"})).To(Equal([]string{"Rock", "Pop", "Punk"}))
+ })
+
+ It("trims whitespace around split values", func() {
+ Expect(conf.SplitTagValue([]string{"Love, Emotional, Ballad"})).To(Equal([]string{"Love", "Emotional", "Ballad"}))
+ })
+
+ // Regression test for https://github.com/navidrome/navidrome/issues/5065
+ //
+ // When multiple ID3v2 frames map to the same logical tag (e.g. TMOO + TXXX:MOOD),
+ // TagLib's PropertyMap merges them into a slice with several entries. Previously
+ // SplitTagValue had a `len(values) != 1` guard that skipped splitting in this case.
+ It("splits each value individually when given multiple inputs", func() {
+ input := []string{"Love, Emotional, Ballad", "Love; Emotional; Ballad"}
+ Expect(conf.SplitTagValue(input)).To(Equal([]string{
+ "Love", "Emotional", "Ballad",
+ "Love", "Emotional", "Ballad",
+ }))
+ })
+
+ It("matches separators case-insensitively when the split pattern allows", func() {
+ c := TagConf{Split: []string{" AND "}}
+ c.SplitRx = compileSplitRegex("test", c.Split)
+ Expect(c.SplitTagValue([]string{"foo and bar AND baz"})).To(Equal([]string{"foo", "bar", "baz"}))
+ })
+
+ It("returns values unchanged when no separators are configured", func() {
+ c := TagConf{}
+ Expect(c.SplitTagValue([]string{"Foo, Bar"})).To(Equal([]string{"Foo, Bar"}))
+ Expect(c.SplitTagValue([]string{"a", "b"})).To(Equal([]string{"a", "b"}))
+ })
+
+ It("returns an empty slice for empty input", func() {
+ Expect(conf.SplitTagValue([]string{})).To(BeEmpty())
+ })
+
+ It("handles a value with no separator as a single-element result", func() {
+ Expect(conf.SplitTagValue([]string{"JustOneMood"})).To(Equal([]string{"JustOneMood"}))
+ })
+
+ It("produces empty strings when separators are adjacent (dedup happens downstream)", func() {
+ // SplitTagValue itself does not filter empties; that is the job of
+ // filterDuplicatedOrEmptyValues in the metadata pipeline.
+ Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"}))
+ })
+ })
+})
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index e75a0e58c..cfdc499e0 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -18,7 +18,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
- . "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
@@ -219,7 +218,7 @@ func (r *artistRepository) Exists(id string) (bool, error) {
func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error {
dba := &dbArtist{Artist: a}
- dba.CreatedAt = P(time.Now())
+ dba.CreatedAt = new(time.Now())
dba.UpdatedAt = dba.CreatedAt
_, err := r.put(dba.ID, dba, colsToUpdate...)
return err
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/persistence/criteria_sql.go b/persistence/criteria_sql.go
index a1bae3170..37e4ae340 100644
--- a/persistence/criteria_sql.go
+++ b/persistence/criteria_sql.go
@@ -3,7 +3,9 @@ package persistence
import (
"errors"
"fmt"
+ "maps"
"reflect"
+ "slices"
"strconv"
"strings"
"time"
@@ -147,7 +149,7 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz
}
or = append(or, cond)
}
- return or, nil
+ return mergeJsonConds(or), nil
case criteria.Is:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Eq(fields)
@@ -381,17 +383,173 @@ type roleCond struct {
func (e roleCond) ToSql() (string, []any, error) {
var cond string
var args []any
- var err error
if e.cond != nil {
- cond, args, err = e.cond.ToSql()
- cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond)
+ innerSQL, innerArgs, err := roleCondSQL(e.cond)
+ if err != nil {
+ return "", nil, err
+ }
+ cond = roleExistsSQL(innerSQL)
+ args = append([]any{e.role}, innerArgs...)
} else {
- cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role)
+ cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)"
+ args = []any{e.role}
}
if e.not {
cond = "not " + cond
}
- return cond, args, err
+ return cond, args, nil
+}
+
+// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name.
+func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) {
+ sql, args, err := cond.ToSql()
+ if err != nil {
+ return "", nil, err
+ }
+ return strings.ReplaceAll(sql, "value", "artist.name"), args, nil
+}
+
+// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery.
+func roleExistsSQL(innerCond string) string {
+ return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+
+ "where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond)
+}
+
+// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery
+// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper
+// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum.
+const jsonCondBatchSize = 350
+
+// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same
+// field within an OR group into batched EXISTS subqueries with the conditions ORed inside.
+// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically
+// improving performance for smart playlists with many patterns.
+func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer {
+ type condEntry struct {
+ index int
+ cond squirrel.Sqlizer
+ }
+ type group struct {
+ entries []condEntry
+ isRole bool
+ numeric bool
+ tag string
+ }
+ groups := make(map[string]*group)
+ for i, s := range or {
+ switch c := s.(type) {
+ case roleCond:
+ if c.not || c.cond == nil {
+ continue
+ }
+ g, exists := groups["role:"+c.role]
+ if !exists {
+ g = &group{isRole: true}
+ groups["role:"+c.role] = g
+ }
+ g.entries = append(g.entries, condEntry{index: i, cond: c.cond})
+ case tagCond:
+ if c.not || c.cond == nil {
+ continue
+ }
+ g, exists := groups["tag:"+c.tag]
+ if !exists {
+ g = &group{tag: c.tag, numeric: c.numeric}
+ groups["tag:"+c.tag] = g
+ }
+ g.entries = append(g.entries, condEntry{index: i, cond: c.cond})
+ }
+ }
+
+ merged := false
+ remove := make(map[int]bool)
+ var additions []squirrel.Sqlizer
+ for _, key := range slices.Sorted(maps.Keys(groups)) {
+ g := groups[key]
+ if len(g.entries) < 2 {
+ continue
+ }
+ merged = true
+ for _, e := range g.entries {
+ remove[e.index] = true
+ }
+ conds := make([]squirrel.Sqlizer, len(g.entries))
+ for i, e := range g.entries {
+ conds[i] = e.cond
+ }
+ if g.isRole {
+ role := key[len("role:"):]
+ for batch := range slices.Chunk(conds, jsonCondBatchSize) {
+ additions = append(additions, roleCondGroup{role: role, conds: batch})
+ }
+ } else {
+ for batch := range slices.Chunk(conds, jsonCondBatchSize) {
+ additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch})
+ }
+ }
+ }
+
+ if !merged {
+ return or
+ }
+
+ result := make(squirrel.Or, 0, len(or)-len(remove)+len(additions))
+ for i, s := range or {
+ if !remove[i] {
+ result = append(result, s)
+ }
+ }
+ result = append(result, additions...)
+ return result
+}
+
+// roleCondGroup represents multiple role conditions for the same role, merged into
+// a single EXISTS subquery for performance.
+type roleCondGroup struct {
+ role string
+ conds []squirrel.Sqlizer
+}
+
+func (g roleCondGroup) ToSql() (string, []any, error) {
+ innerParts := make([]string, 0, len(g.conds))
+ allArgs := []any{g.role}
+ for _, c := range g.conds {
+ part, args, err := roleCondSQL(c)
+ if err != nil {
+ return "", nil, err
+ }
+ innerParts = append(innerParts, part)
+ allArgs = append(allArgs, args...)
+ }
+ cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")")
+ return cond, allArgs, nil
+}
+
+// tagCondGroup represents multiple tag conditions for the same tag, merged into
+// a single EXISTS subquery for performance.
+type tagCondGroup struct {
+ tag string
+ numeric bool
+ conds []squirrel.Sqlizer
+}
+
+func (g tagCondGroup) ToSql() (string, []any, error) {
+ innerParts := make([]string, 0, len(g.conds))
+ var allArgs []any
+ for _, c := range g.conds {
+ part, args, err := c.ToSql()
+ if err != nil {
+ return "", nil, err
+ }
+ if g.numeric {
+ part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)")
+ }
+ innerParts = append(innerParts, part)
+ allArgs = append(allArgs, args...)
+ }
+ cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))",
+ g.tag, strings.Join(innerParts, " OR "))
+ return cond, allArgs, nil
}
func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) {
diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go
new file mode 100644
index 000000000..d901e9eda
--- /dev/null
+++ b/persistence/criteria_sql_benchmark_test.go
@@ -0,0 +1,236 @@
+package persistence
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/pocketbase/dbx"
+)
+
+const (
+ benchNumArtists = 1_000
+ benchNumTracks = 40_000
+ benchNumPatterns = 500
+ benchArtistsPerTrack = 3
+)
+
+// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance
+// between the current implementation (merged join-table via criteria pipeline) and
+// the old baseline (unmerged json_tree subqueries).
+func BenchmarkSmartPlaylistRole(b *testing.B) {
+ configtest.SetupConfig()
+ tmpDir := b.TempDir()
+ conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db")
+ cleanup := db.Init(context.Background())
+ defer cleanup()
+ log.SetLevel(log.LevelFatal)
+
+ conn := dbx.NewFromDB(db.Db(), db.Dialect)
+ ctx := log.NewContext(context.Background())
+ user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true}
+ ctx = request.WithUser(ctx, user)
+
+ setupBenchData(b, ctx, conn, user)
+ criteria.AddRoles([]string{"artist"})
+
+ // Build the criteria expression: 500 "contains artist" patterns in an OR group
+ anyExprs := make(criteria.Any, benchNumPatterns)
+ for i := range benchNumPatterns {
+ anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)}
+ }
+ expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500}
+
+ b.Run("Current", func(b *testing.B) {
+ benchmarkCriteriaPipeline(b, ctx, expr)
+ })
+ b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) {
+ benchmarkUnmergedJSONTree(b, ctx)
+ })
+}
+
+// benchmarkCriteriaPipeline runs the criteria through the actual production code path:
+// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query.
+func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) {
+ b.Helper()
+
+ cSQL := newSmartPlaylistCriteria(expr)
+
+ // Build the full query matching buildSmartPlaylistQuery + addCriteria
+ sq := squirrel.Select("media_file.id").From("media_file")
+ cond, err := cSQL.Where()
+ if err != nil {
+ b.Fatal(err)
+ }
+ sq = sq.Where(cond)
+ if expr.Limit > 0 {
+ sq = sq.Limit(uint64(expr.Limit))
+ }
+ if order := cSQL.OrderBy(); order != "" {
+ sq = sq.OrderBy(order)
+ }
+
+ query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql()
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ runBenchQuery(b, ctx, query, args)
+}
+
+// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS
+// subqueries (the pre-optimization baseline).
+func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) {
+ b.Helper()
+
+ var sb strings.Builder
+ sb.WriteString("SELECT media_file.id FROM media_file WHERE (")
+ args := make([]any, 0, benchNumPatterns)
+ for i := range benchNumPatterns {
+ if i > 0 {
+ sb.WriteString(" OR ")
+ }
+ sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)")
+ args = append(args, fmt.Sprintf("%%Artist %04d%%", i))
+ }
+ sb.WriteString(") ORDER BY media_file.title LIMIT 500")
+
+ runBenchQuery(b, ctx, sb.String(), args)
+}
+
+func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) {
+ b.Helper()
+ sqlDB := db.Db()
+ b.ResetTimer()
+ for range b.N {
+ rows, err := sqlDB.QueryContext(ctx, query, args...)
+ if err != nil {
+ b.Fatal(err)
+ }
+ for rows.Next() {
+ var id string
+ _ = rows.Scan(&id)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) {
+ b.Helper()
+
+ sqlDB := db.Db()
+
+ ur := NewUserRepository(ctx, conn)
+ if err := ur.Put(&user); err != nil {
+ b.Fatal(err)
+ }
+ if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil {
+ b.Fatal(err)
+ }
+
+ tx, err := sqlDB.Begin()
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ // Create artists
+ artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)")
+ if err != nil {
+ b.Fatal(err)
+ }
+ for i := range benchNumArtists {
+ if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil {
+ b.Fatal(err)
+ }
+ }
+ artistStmt.Close()
+
+ // Ensure folder exists
+ folderID := "bench-folder"
+ if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil {
+ b.Fatal(err)
+ }
+
+ // Create media files with participants JSON, cycling through artists
+ mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id,
+ duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ // Populate media_file_artists join table
+ mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)")
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ for i := range benchNumTracks {
+ trackID := fmt.Sprintf("track-%05d", i)
+
+ // Assign benchArtistsPerTrack artists to each track, cycling through the pool
+ artistEntries := make([]map[string]string, benchArtistsPerTrack)
+ for a := range benchArtistsPerTrack {
+ artistIdx := (i + a) % benchNumArtists
+ artistEntries[a] = map[string]string{
+ "id": fmt.Sprintf("artist-%04d", artistIdx),
+ "name": fmt.Sprintf("Artist %04d", artistIdx),
+ }
+ }
+ primaryArtistIdx := i % benchNumArtists
+ primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx)
+ primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx)
+
+ participants := map[string][]map[string]string{"artist": artistEntries}
+ participantsJSON, _ := json.Marshal(participants)
+
+ if _, err := mfStmt.Exec(
+ trackID,
+ fmt.Sprintf("music/%s.mp3", trackID),
+ fmt.Sprintf("Track %05d", i),
+ "Bench Album",
+ primaryArtistName,
+ primaryArtistID,
+ "bench-album",
+ 180, 2024, 5000000, "mp3",
+ "{}",
+ string(participantsJSON),
+ "[]",
+ 1, folderID, trackID, "mp3",
+ ); err != nil {
+ b.Fatal(err)
+ }
+
+ // Insert all artist associations into the join table
+ for a := range benchArtistsPerTrack {
+ artistIdx := (i + a) % benchNumArtists
+ artistID := fmt.Sprintf("artist-%04d", artistIdx)
+ if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil {
+ b.Fatal(err)
+ }
+ }
+ }
+ mfStmt.Close()
+ mfaStmt.Close()
+
+ if err := tx.Commit(); err != nil {
+ b.Fatal(err)
+ }
+
+ b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns",
+ benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns)
+}
diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go
index ae2695a4d..5c8909e1c 100644
--- a/persistence/criteria_sql_test.go
+++ b/persistence/criteria_sql_test.go
@@ -1,6 +1,8 @@
package persistence
import (
+ "fmt"
+ "strings"
"time"
"github.com/navidrome/navidrome/model"
@@ -56,9 +58,9 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6),
Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"),
Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"),
- Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
- Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"),
- Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
+ Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name = ?)", "artist", "u2"),
+ Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "composer", "%Lennon%"),
+ Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "artist", "%u2%"),
// ReplayGain fields
Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0),
Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0),
@@ -70,9 +72,9 @@ var _ = Describe("Smart playlist criteria SQL", func() {
"exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
// isMissing — roles
Entry("isMissing role [true]", criteria.IsMissing{"artist": true},
- "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"),
+ "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"),
Entry("isMissing role [false]", criteria.IsMissing{"artist": false},
- "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"),
+ "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"),
// isPresent — tags
Entry("isPresent tag [true]", criteria.IsPresent{"genre": true},
"exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
@@ -80,9 +82,9 @@ var _ = Describe("Smart playlist criteria SQL", func() {
"not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
// isPresent — roles
Entry("isPresent role [true]", criteria.IsPresent{"composer": true},
- "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"),
+ "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
Entry("isPresent role [false]", criteria.IsPresent{"composer": false},
- "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"),
+ "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
)
Describe("playlist permissions", func() {
@@ -204,6 +206,146 @@ var _ = Describe("Smart playlist criteria SQL", func() {
}
})
+ Describe("JSON condition merging", func() {
+ It("merges multiple role conditions in an OR group into a single EXISTS", func() {
+ expr := criteria.Any{
+ criteria.Contains{"artist": "Beatles"},
+ criteria.Contains{"artist": "Kraftwerk"},
+ criteria.Contains{"artist": "Pink Floyd"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal("(exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and (artist.name LIKE ? OR artist.name LIKE ? OR artist.name LIKE ?)))"))
+ Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%", "%Pink Floyd%"))
+ })
+
+ It("does not merge role conditions from different roles", func() {
+ expr := criteria.Any{
+ criteria.Contains{"artist": "Beatles"},
+ criteria.Contains{"composer": "Lennon"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, _, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("mfa.role = ?"))
+ // Two separate EXISTS since roles differ
+ Expect(strings.Count(sql, "exists")).To(Equal(2))
+ })
+
+ It("does not merge negated role conditions", func() {
+ expr := criteria.Any{
+ criteria.NotContains{"artist": "Beatles"},
+ criteria.NotContains{"artist": "Kraftwerk"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, _, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // Two separate "not exists" since they are negated
+ Expect(strings.Count(sql, "not exists")).To(Equal(2))
+ })
+
+ It("batches large groups to avoid SQLite expression tree depth limit", func() {
+ // Create jsonCondBatchSize + 1 conditions to trigger batching into 2 groups
+ anyExprs := make(criteria.Any, jsonCondBatchSize+1)
+ for i := range anyExprs {
+ anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)}
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // Should produce 2 EXISTS subqueries (one batch of jsonCondBatchSize, one of 1)
+ Expect(strings.Count(sql, "exists")).To(Equal(2))
+ // First batch has jsonCondBatchSize patterns, second has 1 => total args:
+ // 2 roles + (jsonCondBatchSize + 1) patterns
+ Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1))
+ })
+
+ It("merges role conditions while preserving non-role conditions", func() {
+ expr := criteria.Any{
+ criteria.Contains{"title": "Love"},
+ criteria.Contains{"artist": "Beatles"},
+ criteria.Contains{"artist": "Kraftwerk"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("media_file.title LIKE ?"))
+ Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?"))
+ Expect(args).To(HaveExactElements("%Love%", "artist", "%Beatles%", "%Kraftwerk%"))
+ })
+
+ It("merges multiple tag conditions in an OR group into a single EXISTS", func() {
+ expr := criteria.Any{
+ criteria.Contains{"genre": "Rock"},
+ criteria.Contains{"genre": "Metal"},
+ criteria.Contains{"genre": "Punk"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal("(exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and (value LIKE ? OR value LIKE ? OR value LIKE ?)))"))
+ Expect(args).To(HaveExactElements("%Rock%", "%Metal%", "%Punk%"))
+ })
+
+ It("does not merge tag conditions from different tags", func() {
+ expr := criteria.Any{
+ criteria.Contains{"genre": "Rock"},
+ criteria.Contains{"mood": "Happy"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, _, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(strings.Count(sql, "exists")).To(Equal(2))
+ })
+
+ It("does not merge negated tag conditions", func() {
+ expr := criteria.Any{
+ criteria.NotContains{"genre": "Rock"},
+ criteria.NotContains{"genre": "Metal"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, _, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(strings.Count(sql, "not exists")).To(Equal(2))
+ })
+
+ It("merges role and tag conditions independently", func() {
+ expr := criteria.Any{
+ criteria.Contains{"artist": "Beatles"},
+ criteria.Contains{"artist": "Kraftwerk"},
+ criteria.Contains{"genre": "Rock"},
+ criteria.Contains{"genre": "Metal"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // Two merged EXISTS: one for roles, one for tags
+ Expect(strings.Count(sql, "exists")).To(Equal(2))
+ Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?"))
+ Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?"))
+ Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name
+ })
+ })
+
Describe("joins", func() {
It("excludes sort-only joins from expression joins", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"}
diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go
index 53f324bf4..22443284f 100644
--- a/persistence/genre_repository.go
+++ b/persistence/genre_repository.go
@@ -14,9 +14,8 @@ type genreRepository struct {
}
func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository {
- genreFilter := model.TagGenre
return &genreRepository{
- baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter),
+ baseTagRepository: newBaseTagRepository(ctx, db, new(model.TagGenre)),
}
}
diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go
index ebc247d77..abc5c4b6a 100644
--- a/persistence/persistence_suite_test.go
+++ b/persistence/persistence_suite_test.go
@@ -13,7 +13,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
@@ -103,7 +102,7 @@ var (
songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
AlbumID: "103",
Path: p("kraft/radio/antenna.mp3"),
- RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0),
+ RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0),
})
songAntennaWithLyrics = mf(model.MediaFile{
ID: "1005",
@@ -162,8 +161,6 @@ func p(path string) string {
return filepath.FromSlash(path)
}
-// Initialize test DB
-// TODO Load this data setup from file(s)
var _ = BeforeSuite(func() {
conn := GetDBXBuilder()
ctx := log.NewContext(context.TODO())
@@ -187,8 +184,7 @@ var _ = BeforeSuite(func() {
alr := NewAlbumRepository(ctx, conn).(*albumRepository)
for i := range testAlbums {
- a := testAlbums[i]
- err := alr.Put(&a)
+ err := alr.Put(new(testAlbums[i]))
if err != nil {
panic(err)
}
@@ -196,8 +192,7 @@ var _ = BeforeSuite(func() {
arr := NewArtistRepository(ctx, conn)
for i := range testArtists {
- a := testArtists[i]
- err := arr.Put(&a)
+ err := arr.Put(new(testArtists[i]))
if err != nil {
panic(err)
}
@@ -243,8 +238,7 @@ var _ = BeforeSuite(func() {
rar := NewRadioRepository(ctx, conn)
for i := range testRadios {
- r := testRadios[i]
- err := rar.Put(&r)
+ err := rar.Put(new(testRadios[i]))
if err != nil {
panic(err)
}
diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go
index c952b42b1..ba69ec746 100644
--- a/persistence/playqueue_repository.go
+++ b/persistence/playqueue_repository.go
@@ -89,8 +89,7 @@ func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error)
sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId})
var res playQueue
err := r.queryOne(sel, &res)
- q := r.toModel(&res)
- return &q, err
+ return new(r.toModel(&res)), err
}
func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue {
diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go
index 88a31ac49..05628ca41 100644
--- a/persistence/radio_repository_test.go
+++ b/persistence/radio_repository_test.go
@@ -11,10 +11,6 @@ import (
. "github.com/onsi/gomega"
)
-var (
- NewId string = "123-456-789"
-)
-
var _ = Describe("RadioRepository", func() {
var repo model.RadioRepository
@@ -34,8 +30,7 @@ var _ = Describe("RadioRepository", func() {
}
for i := range testRadios {
- r := testRadios[i]
- err := repo.Put(&r)
+ err := repo.Put(new(testRadios[i]))
if err != nil {
panic(err)
}
@@ -140,7 +135,7 @@ var _ = Describe("RadioRepository", func() {
It("returns an existing item", func() {
res, err := repo.Get(radioWithHomePage.ID)
- Expect(err).To((BeNil()))
+ Expect(err).To(BeNil())
Expect(res.ID).To(Equal(radioWithHomePage.ID))
})
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..997409146 100644
--- a/plugins/host_kvstore_test.go
+++ b/plugins/host_kvstore_test.go
@@ -34,11 +34,10 @@ 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"
- service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize})
+ service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")})
Expect(err).ToNot(HaveOccurred())
})
@@ -253,8 +252,7 @@ var _ = Describe("KVStoreService", func() {
// Close and reopen the service (simulating restart)
Expect(service.Close()).To(Succeed())
- maxSize := "1KB"
- service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize})
+ service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")})
Expect(err).ToNot(HaveOccurred())
defer service2.Close()
@@ -452,8 +450,7 @@ var _ = Describe("KVStoreService", func() {
closeCtx, closeCancel := context.WithCancel(ctx)
defer closeCancel()
- maxSize := "1KB"
- svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize})
+ svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: new("1KB")})
Expect(err).ToNot(HaveOccurred())
// Insert an expired key so cleanup has work to do
@@ -705,9 +702,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..eb5b17a02 100644
--- a/plugins/host_library_test.go
+++ b/plugins/host_library_test.go
@@ -35,8 +35,7 @@ var _ = Describe("LibraryService", Ordered, func() {
Describe("GetLibrary", func() {
It("should return library metadata without filesystem permission", func() {
- reason := "test"
- service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl)
+ service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl)
lib := &model.Library{
ID: 1,
@@ -67,8 +66,7 @@ var _ = Describe("LibraryService", Ordered, func() {
})
It("should return library metadata with filesystem permission", func() {
- reason := "test"
- service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl)
+ service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl)
lib := &model.Library{
ID: 2,
@@ -93,8 +91,7 @@ var _ = Describe("LibraryService", Ordered, func() {
})
It("should return error for non-existent library", func() {
- reason := "test"
- service = newLibraryService(ds, &LibraryPermission{Reason: &reason}, nil, true).(*libraryServiceImpl)
+ service = newLibraryService(ds, &LibraryPermission{Reason: new("test")}, nil, true).(*libraryServiceImpl)
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{})
@@ -107,8 +104,7 @@ var _ = Describe("LibraryService", Ordered, func() {
Describe("GetAllLibraries", func() {
It("should return all libraries without filesystem permission", func() {
- reason := "test"
- service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl)
+ service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@@ -130,8 +126,7 @@ var _ = Describe("LibraryService", Ordered, func() {
})
It("should return all libraries with filesystem permission", func() {
- reason := "test"
- service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl)
+ service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@@ -152,10 +147,8 @@ var _ = Describe("LibraryService", Ordered, func() {
})
Describe("Library Access Filtering", func() {
- It("should only return libraries in the allowed list", func() {
- reason := "test"
- // Only allow library ID 2
- service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
+ It("should only return libraries in the allowed list", func() { // Only allow library ID 2
+ service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@@ -173,10 +166,8 @@ var _ = Describe("LibraryService", Ordered, func() {
Expect(results[0].Name).To(Equal("Jazz"))
})
- It("should return error when getting a library not in the allowed list", func() {
- reason := "test"
- // Only allow library ID 2
- service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
+ It("should return error when getting a library not in the allowed list", func() { // Only allow library ID 2
+ service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@@ -192,10 +183,8 @@ var _ = Describe("LibraryService", Ordered, func() {
Expect(err.Error()).To(ContainSubstring("not accessible"))
})
- It("should allow access to a library in the allowed list", func() {
- reason := "test"
- // Only allow library ID 2
- service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
+ It("should allow access to a library in the allowed list", func() { // Only allow library ID 2
+ service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@@ -211,10 +200,8 @@ var _ = Describe("LibraryService", Ordered, func() {
Expect(result.Name).To(Equal("Jazz"))
})
- It("should return empty list when no libraries are allowed and allLibraries is false", func() {
- reason := "test"
- // No libraries allowed
- service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{}, false).(*libraryServiceImpl)
+ It("should return empty list when no libraries are allowed and allLibraries is false", func() { // No libraries allowed
+ service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@@ -229,10 +216,8 @@ var _ = Describe("LibraryService", Ordered, func() {
Expect(results).To(HaveLen(0))
})
- It("should return all libraries when allLibraries is true regardless of allowed list", func() {
- reason := "test"
- // allLibraries=true should ignore the allowed list
- service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{1}, true).(*libraryServiceImpl)
+ It("should return all libraries when allLibraries is true regardless of allowed list", func() { // allLibraries=true should ignore the allowed list
+ service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{1}, true).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@@ -263,7 +248,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 +342,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.go b/plugins/host_websocket.go
index 74238a422..eef1e6236 100644
--- a/plugins/host_websocket.go
+++ b/plugins/host_websocket.go
@@ -302,8 +302,7 @@ func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
closeCode := websocket.CloseNoStatusReceived
closeReason := ""
- var ce *websocket.CloseError
- if errors.As(err, &ce) {
+ if ce, ok := errors.AsType[*websocket.CloseError](err); ok {
closeCode = ce.Code
closeReason = ce.Text
}
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_cache_test.go b/plugins/manager_cache_test.go
index f985fcd84..3dbfa45ee 100644
--- a/plugins/manager_cache_test.go
+++ b/plugins/manager_cache_test.go
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
+ "strconv"
"time"
"github.com/dustin/go-humanize"
@@ -143,7 +144,7 @@ var _ = Describe("purgeCacheBySize", func() {
// Create 5 files, 1MiB each (total 5MiB)
for i := range 5 {
- path := filepath.Join(cacheDir, filepath.Join("dir", "file"+string(rune('0'+i))+".bin"))
+ path := filepath.Join(cacheDir, filepath.Join("dir", "file"+strconv.Itoa(i)+".bin"))
createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour))
}
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/manifest_test.go b/plugins/manifest_test.go
index c45a480eb..0d371d14a 100644
--- a/plugins/manifest_test.go
+++ b/plugins/manifest_test.go
@@ -140,11 +140,10 @@ var _ = Describe("Manifest", func() {
})
It("returns true when threads feature has a reason", func() {
- reason := "Required for concurrent processing"
m := &Manifest{
Experimental: &Experimental{
Threads: &ThreadsFeature{
- Reason: &reason,
+ Reason: new("Required for concurrent processing"),
},
},
}
diff --git a/plugins/package_test.go b/plugins/package_test.go
index fa76ddd94..3d3fec022 100644
--- a/plugins/package_test.go
+++ b/plugins/package_test.go
@@ -135,12 +135,11 @@ var _ = Describe("ndpPackage", func() {
Describe("readManifest", func() {
It("should read only the manifest without loading wasm", func() {
ndpPath := filepath.Join(tmpDir, "test.ndp")
- desc := "A test plugin"
manifest := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Version: "1.0.0",
- Description: &desc,
+ Description: new("A test plugin"),
}
wasmBytes := make([]byte, 1024*1024) // 1MB of zeros
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/et.json b/resources/i18n/et.json
new file mode 100644
index 000000000..d511c1246
--- /dev/null
+++ b/resources/i18n/et.json
@@ -0,0 +1,723 @@
+{
+ "languageName": "eesti keel",
+ "resources": {
+ "song": {
+ "name": "Laul |||| Laulud",
+ "fields": {
+ "albumArtist": "Albumi esitaja",
+ "duration": "Kestus",
+ "trackNumber": "Nr",
+ "playCount": "Esituskordi",
+ "title": "Pealkiri",
+ "artist": "Esitaja",
+ "album": "Album",
+ "path": "Faili asukoht",
+ "genre": "Žanr",
+ "compilation": "Kogumik",
+ "year": "Aasta",
+ "size": "Faili suurus",
+ "updatedAt": "Uuendatud",
+ "bitRate": "Bitikiirus",
+ "discSubtitle": "Plaadi alapealkiri",
+ "starred": "Märgi lemmikuks",
+ "comment": "Kommentaar",
+ "rating": "Hinnang",
+ "quality": "Kvaliteet",
+ "bpm": "BPM",
+ "playDate": "Viimati esitatud",
+ "channels": "Kanaleid",
+ "createdAt": "Lisamise kuupäev",
+ "grouping": "Rühmitamine",
+ "mood": "Meeleolu",
+ "participants": "Täiendavad osalejad",
+ "tags": "Täiendavad sildid",
+ "mappedTags": "Tuvastatud sildid",
+ "rawTags": "Sildid töötlemata vaates",
+ "bitDepth": "Bitisügavus",
+ "sampleRate": "Diskreetmisagedus",
+ "missing": "Puudub",
+ "libraryName": "Kogumik",
+ "composer": "Helilooja",
+ "disc": "%{discNumber}. plaat",
+ "albumGain": "Albumikohane esitusvaljuse tundlikkus",
+ "trackGain": "Rajakohane esitusvaljuse tundlikkus"
+ },
+ "actions": {
+ "addToQueue": "Esita hiljem",
+ "playNow": "Esita kohe",
+ "addToPlaylist": "Lisa esitusloendisse",
+ "shuffleAll": "Sega kõik",
+ "download": "Laadi alla",
+ "playNext": "Esita järgmisena",
+ "info": "Loo teave",
+ "showInPlaylist": "Näita esitusloendis",
+ "instantMix": "Kohene miks"
+ }
+ },
+ "album": {
+ "name": "Album |||| Albumid",
+ "fields": {
+ "albumArtist": "Albumi esitaja",
+ "artist": "Esitaja",
+ "duration": "Kestus",
+ "songCount": "laulu",
+ "playCount": "Esituskordi",
+ "name": "Nimi",
+ "genre": "Žanr",
+ "compilation": "Kogumik",
+ "year": "Aasta",
+ "updatedAt": "Uuendatud",
+ "comment": "Kommentaar",
+ "rating": "Hinnangud",
+ "createdAt": "Lisamise kuupäev",
+ "size": "Suurus",
+ "originalDate": "Originaal",
+ "releaseDate": "Avaldatud",
+ "releases": "Väljalase ||| Väljalasked",
+ "released": "Avaldatud",
+ "recordLabel": "Plaadifirma",
+ "catalogNum": "Tunnus kataloogides",
+ "releaseType": "Tüüp",
+ "grouping": "Grupeerimine",
+ "media": "Meedium",
+ "mood": "Meeleolu",
+ "date": "Salvestuskuupäev",
+ "missing": "Puudu",
+ "libraryName": "Kogumik"
+ },
+ "actions": {
+ "playAll": "Esita",
+ "playNext": "Esita järgmisena",
+ "addToQueue": "Esita hiljem",
+ "shuffle": "Sega lood",
+ "addToPlaylist": "Lisa esitusloendisse",
+ "download": "Laadi alla",
+ "info": "Albumi teave",
+ "share": "Jaga"
+ },
+ "lists": {
+ "all": "Kõik",
+ "random": "Juhuslik",
+ "recentlyAdded": "Hiljuti lisatud",
+ "recentlyPlayed": "Hiljuti esitatud",
+ "mostPlayed": "Enimesitatud",
+ "starred": "Lemmikud",
+ "topRated": "Kõrgeima hinnanguga"
+ }
+ },
+ "artist": {
+ "name": "Esitaja |||| Esitajad",
+ "fields": {
+ "name": "Nimi",
+ "albumCount": "Albumeid",
+ "songCount": "Lugusid",
+ "playCount": "Esituskordi",
+ "rating": "Hinnang",
+ "genre": "Žanr",
+ "size": "Suurus",
+ "role": "Roll",
+ "missing": "Puudub"
+ },
+ "roles": {
+ "albumartist": "Albumi esitaja ||| Albumi esitajad",
+ "artist": "Esitaja ||| Esitajad",
+ "composer": "Helilooja ||| Heliloojad",
+ "conductor": "Dirigent ||| Dirigendid",
+ "lyricist": "Laulusõnade autor ||| Laulusõnade autorid",
+ "arranger": "Seade autor ||| Seade autorid",
+ "producer": "Produtsent ||| Produtsendid",
+ "director": "Lavastaja ||| Lavastajad",
+ "engineer": "Helirežissöör ||| Helirežissöörid",
+ "mixer": "Miksija ||| Miksijad",
+ "remixer": "Remiksija ||| Remiksijad",
+ "djmixer": "DJ-versiooni remiksija ||| DJ-versiooni remiksijad",
+ "performer": "Esineja ||| Esinejad",
+ "maincredit": "Albumi esitaja või Esitaja ||| Albumi esitajad või Esitajad"
+ },
+ "actions": {
+ "shuffle": "Sega",
+ "radio": "Raadio",
+ "topSongs": "Populaarsed lood"
+ }
+ },
+ "user": {
+ "name": "Kasutaja |||| Kasutajad",
+ "fields": {
+ "userName": "Kasutajanimi",
+ "isAdmin": "On peakasutaja",
+ "lastLoginAt": "Viimane sisselogimine",
+ "updatedAt": "Uuendatud",
+ "name": "Nimi",
+ "password": "Salasõna",
+ "createdAt": "Loodud",
+ "changePassword": "Kas soovid salasõna muuta?",
+ "currentPassword": "Senine salasõna",
+ "newPassword": "Uus salasõna",
+ "token": "Tunnusluba",
+ "lastAccessAt": "Viimasti avatud",
+ "libraries": "Kogumikud"
+ },
+ "helperTexts": {
+ "name": "Sinu nime muudatused on näha järgmisel sisselogimisel",
+ "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikus või jäta vaikimisi väärtuse kasutamiseks tühjaks"
+ },
+ "notifications": {
+ "created": "Kasutaja on lisatud",
+ "updated": "Kasutaja andmed on uuendatud",
+ "deleted": "Kasutaja on kustutatud"
+ },
+ "message": {
+ "listenBrainzToken": "Sisesta oma ListenBrainzi tunnusluba.",
+ "clickHereForToken": "Tunnusloa saamiseks klõpsi siin",
+ "selectAllLibraries": "Vali kõik kogumikud",
+ "adminAutoLibraries": "Peakasutajatel on automaatselt ligipääs kõikidele kogumikele"
+ },
+ "validation": {
+ "librariesRequired": "Vähemalt üks kogumik peab olema valitud muude, kui peakasutajate jaoks"
+ }
+ },
+ "player": {
+ "name": "Meediaesitaja |||| Meediaesitajad",
+ "fields": {
+ "name": "Nimi",
+ "transcodingId": "Teisendamine",
+ "maxBitRate": "Maksimaalne bitikiirus",
+ "client": "Klient",
+ "userName": "Kasutajanimi",
+ "lastSeen": "Viimati nähtud",
+ "reportRealPath": "Teata tegelikust asukohast",
+ "scrobbleEnabled": "Saada kraasimisandmed välistesse teenustesse"
+ }
+ },
+ "transcoding": {
+ "name": "Teisendamine |||| Teisendamised",
+ "fields": {
+ "name": "Nimi",
+ "targetFormat": "Sihtvorming",
+ "defaultBitRate": "Vaikimisi bitikiirus",
+ "command": "Käsk"
+ }
+ },
+ "playlist": {
+ "name": "Esitusloend ||| Esitusloendid",
+ "fields": {
+ "name": "Nimi",
+ "duration": "Kestus",
+ "ownerName": "Omanik",
+ "public": "Avalik",
+ "updatedAt": "Muudetud",
+ "createdAt": "Loodud",
+ "songCount": "Lood",
+ "comment": "Kommentaar",
+ "sync": "Automaatne import",
+ "path": "Impordi siit"
+ },
+ "actions": {
+ "selectPlaylist": "Valo esitusloend:",
+ "addNewPlaylist": "Loo „%{name}\"“",
+ "export": "Ekspordi",
+ "makePublic": "Muuda avalikuks",
+ "makePrivate": "Muuda privaatseks",
+ "saveQueue": "Salvesta esitusjärjekord esitusloendina",
+ "searchOrCreate": "Otsi esitusloendeid või uue loomiseks sisesta nimi...",
+ "pressEnterToCreate": "Uue esitusloendi lisamiseks vajuta sisestusklahvi",
+ "removeFromSelection": "Eemalda valikust"
+ },
+ "message": {
+ "duplicate_song": "Lisa topeltlood",
+ "song_exist": "Tundub, et oled esitusloendisse lisamas topeltkirjeid. Kas tahad nii jätkata või soovid topeltkirjed vahele jätta?",
+ "noPlaylistsFound": "Esitusloendeid ei leidu",
+ "noPlaylists": "Esitusloendeid pole saadaval"
+ }
+ },
+ "radio": {
+ "name": "Raadio ||| Raadiod",
+ "fields": {
+ "name": "Nimi",
+ "streamUrl": "Voogedastuse võrguaadress",
+ "homePageUrl": "Avalehe võrguaadress",
+ "updatedAt": "Uuendatud",
+ "createdAt": "Lisatud"
+ },
+ "actions": {
+ "playNow": "Esita kohe"
+ }
+ },
+ "share": {
+ "name": "Jagamine ||| Jagamised",
+ "fields": {
+ "username": "Seda jagas",
+ "url": "Võrguaadress",
+ "description": "Kirjeldus",
+ "contents": "Sisu",
+ "expiresAt": "Aegub",
+ "lastVisitedAt": "Viimati vaadatud",
+ "visitCount": "Külastusi",
+ "format": "Vorming",
+ "maxBitRate": "Maksimaalne bitikiirus",
+ "updatedAt": "Muudetud",
+ "createdAt": "Lisatud",
+ "downloadable": "Kas lubad allalaadimised?"
+ }
+ },
+ "missing": {
+ "name": "Puuduv fail ||| Puuduvad failid",
+ "fields": {
+ "path": "Asukoht",
+ "size": "Suurus",
+ "updatedAt": "Kadumise aeg",
+ "libraryName": "Kogumik"
+ },
+ "actions": {
+ "remove": "Eemalda",
+ "remove_all": "Eemalda kõik"
+ },
+ "notifications": {
+ "removed": "Puuduv(ad) fail(id) on eemaldatud"
+ },
+ "empty": "Puuduvaid faile pole"
+ },
+ "library": {
+ "name": "Kogumik ||| Kogumikud",
+ "fields": {
+ "name": "Nimi",
+ "path": "Asukoht",
+ "remotePath": "Asukoht kaugseadmes",
+ "lastScanAt": "Viimane skaneerimine",
+ "songCount": "Lood",
+ "albumCount": "Albumid",
+ "artistCount": "Esitajad",
+ "totalSongs": "Lood",
+ "totalAlbums": "Albumid",
+ "totalArtists": "Esitajad",
+ "totalFolders": "Kaustad",
+ "totalFiles": "Failid",
+ "totalMissingFiles": "Puuduvad failid",
+ "totalSize": "Kogumaht",
+ "totalDuration": "Kestus",
+ "defaultNewUsers": "Vaikimisi väärtus uutele kasutajatele",
+ "createdAt": "Lisatud",
+ "updatedAt": "Muudetud"
+ },
+ "sections": {
+ "basic": "Põhiteave",
+ "statistics": "Statistika"
+ },
+ "actions": {
+ "scan": "Skaneeri kogumikku",
+ "manageUsers": "Halda kasutajate õigusi",
+ "viewDetails": "Vaata üksikasju",
+ "quickScan": "Kiirskaneerimine",
+ "fullScan": "Täismahuline skaneerimine"
+ },
+ "notifications": {
+ "created": "Kogumiku loomine õnnestus",
+ "updated": "Kogumiku uuendamine õnnestus",
+ "deleted": "Kogumiku kustutamine õnnestus",
+ "scanStarted": "Kogumiku skaneerimine algas",
+ "scanCompleted": "Kogumiku skaneerimine lõppes",
+ "quickScanStarted": "Kiirskaneerimine algas",
+ "fullScanStarted": "Täismahuline skaneerimine algas",
+ "scanError": "Viga skaneerimise käivitamisel. Lisateavet leiad logidest"
+ },
+ "validation": {
+ "nameRequired": "Pead sisestama kogumiku nime",
+ "pathRequired": "Pead sisestama kogumiku asukoha",
+ "pathNotDirectory": "Kogumiku asukoht peab olema kaust",
+ "pathNotFound": "Kogumiku asukoha kausta ei leidu",
+ "pathNotAccessible": "Kogumiku asukoha kaust pole ligipääsetav",
+ "pathInvalid": "Vigane kogumiku asukoha kaust"
+ },
+ "messages": {
+ "deleteConfirm": "Kas oled kindel, et soovid selle kogumiku kustutada? Samaga eemaldad ka kõik seotud andmed ja kasutajate ligipääsu.",
+ "scanInProgress": "Skaneerimine on pooleli...",
+ "noLibrariesAssigned": "Selle kasutajaga pole veel ühtegi kogumikku seotud"
+ }
+ },
+ "plugin": {
+ "name": "Lisamoodul |||| Lisamoodulid",
+ "fields": {
+ "id": "Tunnus",
+ "name": "Nimi",
+ "description": "Kirjeldus",
+ "version": "Versioon",
+ "author": "Autor",
+ "website": "Veebisait",
+ "permissions": "Õigused",
+ "enabled": "Kasutusel",
+ "status": "Olek",
+ "path": "Asukoht",
+ "lastError": "Viga",
+ "hasError": "Viga",
+ "updatedAt": "Uuendatud",
+ "createdAt": "Paigaldatud",
+ "configKey": "Võti",
+ "configValue": "Väärtus",
+ "allUsers": "Luba kõiki kasutajaid",
+ "selectedUsers": "Valitud kasutajad",
+ "allLibraries": "Luba kõik kogumikud",
+ "selectedLibraries": "Valitud kogumikud",
+ "allowWriteAccess": "Luba kirjutusõigused"
+ },
+ "sections": {
+ "status": "Olek",
+ "info": "Lisamooduli teave",
+ "configuration": "Seadistus",
+ "manifest": "Manifest",
+ "usersPermission": "Kasutajate õigused",
+ "libraryPermission": "Kogumike õigused"
+ },
+ "status": {
+ "enabled": "Kasutusel",
+ "disabled": "Pole kasutusel"
+ },
+ "actions": {
+ "enable": "Võta kasutusele",
+ "disable": "Eemalda kasutuselt",
+ "disabledDueToError": "Enne kasutuselevõtmist paranda viga",
+ "disabledUsersRequired": "Enne kasutuselevõtmist vali kasutajad",
+ "disabledLibrariesRequired": "Enne kasutuselevõtmist vali kogumikud",
+ "addConfig": "Lisa seadistus",
+ "rescan": "Skaneeri uuesti"
+ },
+ "notifications": {
+ "enabled": "Lisamoodul on kasutusel",
+ "disabled": "Lisamoodul pole kasutusel",
+ "updated": "Lisamoodul on uuendatud",
+ "error": "Viga lisamooduli uuendamisel"
+ },
+ "validation": {
+ "invalidJson": "Seadistus peab olema koostatud korrektses JSON-vormingus"
+ },
+ "messages": {
+ "configHelp": "Seadista lisamoodulit võti-väärtus paaride abil. Kui lisamoodul seadistamist ei vaja, siis jäta tühjaks.",
+ "clickPermissions": "Üksikasjade vaatamiseks klõpsa õigust",
+ "noConfig": "Ühtegi seadistust pole määratud",
+ "allUsersHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kasutajatele, sealhulgas tulevikus loodavatele.",
+ "noUsers": "Ühtegi kasutajat pole valitud",
+ "permissionReason": "Põhjus",
+ "usersRequired": "See lisamoodul vajab ligipääsu kasutajate teabele. Vali kasutajad, millele ta ligi peaks saama või vali „Kõik kasutajad“.",
+ "allLibrariesHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kogumikele, sealhulgas tulevikus loodavatele.",
+ "noLibraries": "Ühtegi kogumikku pole valitud",
+ "librariesRequired": "See lisamoodul vajab ligipääsu kogumiku teabele. Vali kogumikud, millele ta ligi peaks saama või vali „Kõik kogumikud“.",
+ "requiredHosts": "Nõutavad hostid",
+ "configValidationError": "Seadistuse õigsuse kontrollimine ei õnnestunud:",
+ "schemaRenderError": "Seadistuste vormi lugemine ja töötlemine ei õnnestunud. Lisamooduli ülesehitus/skeem võib olla vigane.",
+ "allowWriteAccessHelp": "Kui valik on kasutusel, siis lisamoodul võib muuta vaid faile kogumike kaustades. Vaikimisi on lisamoodulitel vaid lugemisõigus."
+ },
+ "placeholders": {
+ "configKey": "võti",
+ "configValue": "väärtus"
+ }
+ }
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "Aitäh, et paigaldasite Navidrome'i!",
+ "welcome2": "Alustamiseks lisa peakasutaja",
+ "confirmPassword": "Korda salasõna",
+ "buttonCreateAdmin": "Loo admin",
+ "auth_check_error": "Jätkamiseks palun logi sisse",
+ "user_menu": "Profiil",
+ "username": "Kasutajanimi",
+ "password": "Salasõna",
+ "sign_in": "Logi sisse",
+ "sign_in_error": "Tuvastamine ei toiminud, palun proovi uuesti",
+ "logout": "Logi välja",
+ "insightsCollectionNote": "Navidrome kogub anonüümset kasutustusstatistikat, mille alusel on võimalik projekti paremaks muuta. Klõpsides [siin], saad lugeda lisateavet ning soovi korral sellest kogumisest loobuda"
+ },
+ "validation": {
+ "invalidChars": "Palun kasutage ainult tähti ja numbreid",
+ "passwordDoesNotMatch": "Salasõnad ei kattu",
+ "required": "Nõutav",
+ "minLength": "Pikkus peab olema vähemalt %{min} tähemärki",
+ "maxLength": "Pikkus ei tohi olla üle %{max} tähemärgi",
+ "minValue": "Väärtus peab olema vähemalt %{min}",
+ "maxValue": "Väärtus ei tohi olla enam, kui %{max}",
+ "number": "Sisend peab olema number",
+ "email": "Sisend peab korrektne e-posti aadress",
+ "oneOf": "Väärtus peab olema üks järgnevaist: %{options}",
+ "regex": "Väärtus peab vastama kindlale vormingule (regulaaravaldis): %{pattern}",
+ "unique": "Sisend peab olema unikaalne",
+ "url": "Sisend peab olema korrektne võrguaadress"
+ },
+ "action": {
+ "add_filter": "Lisa filter",
+ "add": "Lisa",
+ "back": "Mine tagasi",
+ "bulk_actions": "1 objekt on valitud |||| %{smart_count} objekti on valitud",
+ "cancel": "Katkesta",
+ "clear_input_value": "Eemalda väärtus",
+ "clone": "Klooni",
+ "confirm": "Kinnita",
+ "create": "Loo",
+ "delete": "Kustuta",
+ "edit": "Muuda",
+ "export": "Ekspordi",
+ "list": "Loend",
+ "refresh": "Uuenda andmed",
+ "remove_filter": "Eemalda see filter",
+ "remove": "Eemalda",
+ "save": "Salvesta",
+ "search": "Otsi",
+ "show": "Näita",
+ "sort": "Järjesta",
+ "undo": "Võta tegevus tagasi",
+ "expand": "Laienda",
+ "close": "Sulge",
+ "open_menu": "Ava menüü",
+ "close_menu": "Sulge menüü",
+ "unselect": "Eemalda valik",
+ "skip": "Jäta vahele",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "share": "Jaga",
+ "download": "Laadi alla"
+ },
+ "boolean": {
+ "true": "Jah",
+ "false": "Ei"
+ },
+ "page": {
+ "create": "Loo %{name}",
+ "dashboard": "Töölaud",
+ "edit": "%{name} #%{id}",
+ "error": "Midagi läks valesti",
+ "list": "%{name}",
+ "loading": "Laadin",
+ "not_found": "Ei leidu",
+ "show": "%{name} #%{id}",
+ "empty": "Nimi on veel puudu - %{name}.",
+ "invite": "Kas sa sooviksid ühe sellise lisada?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "Lohista üleslaadimiseks mõned failid või vali üks failivalijast.",
+ "upload_single": "Lohista üleslaadimiseks fail või vali ta failivalijast."
+ },
+ "image": {
+ "upload_several": "Lohista üleslaadimiseks mõned pildid või vali üks failivalijast.",
+ "upload_single": "Lohista üleslaadimiseks pilt või vali ta failivalijast."
+ },
+ "references": {
+ "all_missing": "Viitenumbrite andmeid ei leidu.",
+ "many_missing": "Vähemalt üks seotud viide ei tundu enam olema saadaval.",
+ "single_missing": "Seotud viide ei tundu enam olema saadaval."
+ },
+ "password": {
+ "toggle_visible": "Peida salasõna",
+ "toggle_hidden": "Näita salasõna"
+ }
+ },
+ "message": {
+ "about": "Teave",
+ "are_you_sure": "Kas oled kindel?",
+ "bulk_delete_content": "Kas sa oled kindel, et soovid kustutada selle objekti - %{name}? |||| Kas sa oled kindel, et soovid kustutada need %{smart_count} objekti?",
+ "bulk_delete_title": "Kustuta %{name} |||| Kustuta %{name} - %{smart_count} kirjet",
+ "delete_content": "Kas oled kindel, et soovid selle objekti kustutada?",
+ "delete_title": "Kustuta %{name} #%{id}",
+ "details": "Üksikasjad",
+ "error": "Tekkis klientrakenduse viga ja päringut polnud võimalik lõpetada.",
+ "invalid_form": "Vormi andmed pole õiged. Palun kontrolli sisestusi",
+ "loading": "Leht on just laadimisel, palun oota hetke",
+ "no": "Ei",
+ "not_found": "Sa kas sisestasid vigase võrguaadressi või klõpsisid vigast linki.",
+ "yes": "Jah",
+ "unsaved_changes": "Mõned sinu muudatused pole salvestatud. Kas sa soovid neist loobuda?"
+ },
+ "navigation": {
+ "no_results": "Tulemusi ei leidu",
+ "no_more_results": "Lehe number %{page} on väljaspool etteantud piire. Proovi eelmist lehte.",
+ "page_out_of_boundaries": "Lehe number %{page} on väljaspool etteantud piire",
+ "page_out_from_end": "Viimasest lehest ei saa edasi minna",
+ "page_out_from_begin": "Esimese lehe ette ei saa minna",
+ "page_range_info": "%{offsetBegin}-%{offsetEnd} - kokku %{total}",
+ "page_rows_per_page": "Kirjeid lehel:",
+ "next": "Edasi",
+ "prev": "Tagasi",
+ "skip_nav": "Mine sisu juurde"
+ },
+ "notification": {
+ "updated": "Objekt on uuendatud |||| %{smart_count} objekti on uuendatud",
+ "created": "Objekt on loodud",
+ "deleted": "Objekt on kustutatud |||| %{smart_count} objekti on kustutatud",
+ "bad_item": "Vigane objekt",
+ "item_doesnt_exist": "Objekti pole olemas",
+ "http_error": "Viga suhtlemisel serveriga",
+ "data_provider_error": "Andmeteenusepakkuja viga. Lisateavet leiad brauseri konsoolist.",
+ "i18n_error": "Vastava keele tõlget ei saa laadida",
+ "canceled": "Tegevus on tühistatud",
+ "logged_out": "Sinu sessioon on lõppenud, palun ühenda uuesti.",
+ "new_version": "Uus versioon on saadaval! Palun laadi see vaade uuesti."
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "Kuvatavad veerud",
+ "layout": "Paigutus",
+ "grid": "Ruudustik",
+ "table": "Tabel"
+ }
+ },
+ "message": {
+ "note": "MÄRGE",
+ "transcodingDisabled": "Transkodeeringu seadistuse muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovite muuta või lisada transkodeerimisega seotud seadistusi, taaskäivitage server %{config} valikuga.",
+ "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese transkodeerimisseadistuste jooksutada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult transkodeerimisseadete muutmiseks.",
+ "songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse",
+ "noPlaylistsAvailable": "Pole saadaval",
+ "delete_user_title": "Kustuta kasutaja „%{name}“",
+ "delete_user_content": "Kas oled kindel, et soovid selle kasutaja ja kõik tema andmed (sh esitusloendid ja eelistused) kustutada?",
+ "notifications_blocked": "Sa oled selle saidi teavitused veebibrauseri seadistusest keelanud",
+ "notifications_not_available": "See veebibrauser kas ei toeta töölauateavitusi või sa ei kasuta Navidrome'i üle https-protokolli",
+ "lastfmLinkSuccess": "Last.fm-i seos on lisatud ja kraasimine on lülitatud sisse",
+ "lastfmLinkFailure": "Last.fm-i seose lisamine ei õnnestunud",
+ "lastfmUnlinkSuccess": "Last.fm-i seos on eemaldatud ja kraasimine on lülitatud välja",
+ "lastfmUnlinkFailure": "Last.fm-i seose eemaldamine ei õnnestunud",
+ "openIn": {
+ "lastfm": "Ava Last.fm-is",
+ "musicbrainz": "Ava MusicBrainzis"
+ },
+ "lastfmLink": "Lisateave...",
+ "listenBrainzLinkSuccess": "ListenBrainzi seos on lisatud ja kraasimine on lülitatud sisse kasutajana: %{user}",
+ "listenBrainzLinkFailure": "ListenBrainzi seose lisamine ei õnnestunud: %{error}",
+ "listenBrainzUnlinkSuccess": "ListenBrainzi seos on eemaldatud ja kraasimine on lülitatud välja",
+ "listenBrainzUnlinkFailure": "ListenBrainzi seose eemaldamine ei õnnestunud",
+ "downloadOriginalFormat": "Laadi alla algses vormingus",
+ "shareOriginalFormat": "Jaga algses vormingus",
+ "shareDialogTitle": "Jaga - %{resource} „%{name}“",
+ "shareBatchDialogTitle": "Jaga - %{resource} |||| Jaga %{smart_count} kirjet - %{resource}",
+ "shareSuccess": "Võrguaadress on kopeeritud lõikelauale: %{url}",
+ "shareFailure": "Viga %{url} võrguaadressi kopeerimisel lõikelauale",
+ "downloadDialogTitle": "Laadi alla - %{resource} '%{name}' (%{size})",
+ "shareCopyToClipboard": "Kopeeri lõikelauale: Ctrl+C, sisestusklahv",
+ "remove_missing_title": "Eemalda puuduvad failid",
+ "remove_missing_content": "Kas sa oled kindel, et soovid valitud puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.",
+ "remove_all_missing_title": "Eemalda kõik puuduvad failid",
+ "remove_all_missing_content": "Kas sa oled kindel, et soovid kõik puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.",
+ "noSimilarSongsFound": "Sarnaseid lugusid ei leidu",
+ "noTopSongsFound": "Populaarsemaid lugusid ei leidu",
+ "startingInstantMix": "Laadin kohest miksi...",
+ "uploadCover": "Laadi kaanepilt üles",
+ "removeCover": "Eemalda kaanepilt",
+ "coverUploaded": "Kaanepilt on uuendatud",
+ "coverRemoved": "Kaanepilt on eemaldatud",
+ "coverUploadError": "Viga kaanepildi üleslaadimisel",
+ "coverRemoveError": "Viga kaanepildi eemaldamisel"
+ },
+ "menu": {
+ "library": "Kogumik",
+ "settings": "Seaded",
+ "version": "Versioon",
+ "theme": "Teema",
+ "personal": {
+ "name": "Isiklik",
+ "options": {
+ "theme": "Teema",
+ "language": "Keel",
+ "defaultView": "Vaikimisi vaade",
+ "desktop_notifications": "Teavitused töölaual",
+ "lastfmScrobbling": "Kraasi Last.fm-i teenusesse",
+ "listenBrainzScrobbling": "Kraasi ListenBrainzi teenusesse",
+ "replaygain": "Esitusvaljuse tundlikkuse režiim",
+ "preAmp": "Esitusvaljuse tundlikkuse eelvõimendus (dB)",
+ "gain": {
+ "none": "Pole kasutusel",
+ "album": "Kasuta albumikohast esitusvaljuse tundlikkust",
+ "track": "Kasuta lookohast esitusvaljuse tundlikkust"
+ },
+ "lastfmNotConfigured": "Last.fm-i API-võti pole seadistatud"
+ }
+ },
+ "albumList": "Albumid",
+ "about": "Rakenduse teave",
+ "playlists": "Esitusloendid",
+ "sharedPlaylists": "Jagatud esitusloendid",
+ "librarySelector": {
+ "allLibraries": "Kõik kogumikud (%{count})",
+ "multipleLibraries": "%{selected} / %{total} kogumikest",
+ "selectLibraries": "Vali kogumikud",
+ "none": "Puudub"
+ }
+ },
+ "player": {
+ "playListsText": "Esitusjärjekord",
+ "openText": "Ava",
+ "closeText": "Sulge",
+ "notContentText": "Muusikat pole",
+ "clickToPlayText": "Klõpsa esitamiseks",
+ "clickToPauseText": "Klõpsa peatamiseks",
+ "nextTrackText": "Järgmine lugu",
+ "previousTrackText": "Eelmine lugu",
+ "reloadText": "Laadi uuesti",
+ "volumeText": "Helivaljus",
+ "toggleLyricText": "Näita/peida laulusõnad",
+ "toggleMiniModeText": "Minimeeri",
+ "destroyText": "Hävita",
+ "downloadText": "Laadi alla",
+ "removeAudioListsText": "Kustuta heliloendid",
+ "clickToDeleteText": "„%{name}“ kustutamiseks klõpsa",
+ "emptyLyricText": "Laulusõnu pole",
+ "playModeText": {
+ "order": "Oma järjekorras",
+ "orderLoop": "Korda",
+ "singleLoop": "Korda üks kord",
+ "shufflePlay": "Sega lood"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "Avaleht",
+ "source": "Lähtekood",
+ "featureRequests": "Arendusettepanekud",
+ "lastInsightsCollection": "Viimati kogutud statistika",
+ "insights": {
+ "disabled": "Pole kasutusel",
+ "waiting": "Ootel"
+ }
+ },
+ "tabs": {
+ "about": "Teave",
+ "config": "Seadistus"
+ },
+ "config": {
+ "configName": "Seadistuse nimi",
+ "environmentVariable": "Keskkonnamuutuja",
+ "currentValue": "Praegune väärtus",
+ "configurationFile": "Seadistusfail",
+ "exportToml": "Ekspordi seadistused (TOML-failina)",
+ "exportSuccess": "Seadistused on eksporditud lõikelauale TOML-failina",
+ "exportFailed": "Seadistuse kopeerimine ei õnnestunud",
+ "devFlagsHeader": "Arendusparameetrid (võivad muutuda või sootuks kaduda)",
+ "devFlagsComment": "Need on katselised seadistused, mis võivad tulevastest versioonidest kaduda",
+ "downloadToml": "Laadi seadistused alla (TOML-failina)"
+ }
+ },
+ "activity": {
+ "title": "Tegevus",
+ "totalScanned": "Kokku skaneeritud kaustu",
+ "quickScan": "Kiirskaneerimine",
+ "fullScan": "Täisskaneerimine",
+ "serverUptime": "Serveri katkematu tööaeg",
+ "serverDown": "POLE VÕRGUS",
+ "scanType": "Tüüp",
+ "status": "Skaneerimisviga",
+ "elapsedTime": "Möödunud aeg",
+ "selectiveScan": "Valikuline"
+ },
+ "help": {
+ "title": "Navidrome'i kiirklahvid",
+ "hotkeys": {
+ "show_help": "Näita seda abiteavet",
+ "toggle_menu": "Lülita menüü külgriba sisse/välja",
+ "toggle_play": "Esita / Peata esitus",
+ "prev_song": "Eelmine lugu",
+ "next_song": "Järgmine lugu",
+ "vol_up": "Heli valjemaks",
+ "vol_down": "Heli vaiksemaks",
+ "toggle_love": "Lisa see lugu lemmikute hulka",
+ "current_song": "Mine esitamisel loo juurde"
+ }
+ },
+ "nowPlaying": {
+ "title": "Hetkel esitamisel",
+ "empty": "Mitte midagi pole hetkel esitamisel",
+ "minutesAgo": "%{smart_count} minut tagasi |||| %{smart_count} minutit tagasi"
+ }
+}
\ No newline at end of file
diff --git a/resources/i18n/sr.json b/resources/i18n/sr.json
index 1cf7e39e7..cf0fc5d2f 100644
--- a/resources/i18n/sr.json
+++ b/resources/i18n/sr.json
@@ -4,45 +4,54 @@
"song": {
"name": "Песма |||| Песме",
"fields": {
- "album": "Албум",
"albumArtist": "Уметник албума",
- "artist": "Уметник",
- "bitDepth": "Битова",
- "bitRate": "Битски проток",
- "bpm": "BPM",
- "channels": "Канала",
- "comment": "Коментар",
- "compilation": "Компилација",
- "createdAt": "Датум додавања",
- "discSubtitle": "Поднаслов диска",
"duration": "Трајање",
+ "trackNumber": "#",
+ "playCount": "Пуштано",
+ "title": "Наслов",
+ "artist": "Уметник",
+ "composer": "Композитор",
+ "album": "Албум",
+ "path": "Путања фајла",
+ "libraryName": "Библиотека",
"genre": "Жанр",
+ "compilation": "Компилација",
+ "year": "Година",
+ "size": "Величина фајла",
+ "updatedAt": "Ажурирано",
+ "bitRate": "Битски проток",
+ "bitDepth": "Битска дубина",
+ "sampleRate": "Учестаност узорковања",
+ "albumGain": "Појачање албума",
+ "trackGain": "Појачање нумере",
+ "channels": "Канали",
+ "disc": "Диск %{discNumber}",
+ "discSubtitle": "Поднаслов диска",
+ "starred": "Омиљено",
+ "comment": "Коментар",
+ "rating": "Рејтинг",
+ "quality": "Квалитет",
+ "bpm": "BPM",
+ "playDate": "Последње пуштано",
+ "createdAt": "Датум додавања",
"grouping": "Груписање",
- "mappedTags": "Мапиране ознаке",
"mood": "Расположење",
"participants": "Додатни учесници",
- "path": "Путања фајла",
- "playCount": "Пуштано",
- "playDate": "Последње пуштано",
- "quality": "Квалитет",
- "rating": "Рејтинг",
- "rawTags": "Сирове ознаке",
- "size": "Величина фајла",
- "starred": "Омиљено",
"tags": "Додатне ознаке",
- "title": "Наслов",
- "trackNumber": "#",
- "updatedAt": "Ажурирано",
- "year": "Година"
+ "mappedTags": "Мапиране ознаке",
+ "rawTags": "Сирове ознаке",
+ "missing": "Недостаје"
},
"actions": {
- "addToPlaylist": "Додај у плејлисту",
"addToQueue": "Пусти касније",
- "download": "Преузми",
- "info": "Прикажи инфо",
- "playNext": "Пусти наредно",
"playNow": "Пусти одмах",
- "shuffleAll": "Измешај све"
+ "addToPlaylist": "Додај у плејлисту",
+ "showInPlaylist": "Прикажи у плејлисти",
+ "shuffleAll": "Измешај све",
+ "download": "Преузми",
+ "playNext": "Пусти наредно",
+ "info": "Прикажи инфо",
+ "instantMix": "Инстант микс"
}
},
"album": {
@@ -50,46 +59,48 @@
"fields": {
"albumArtist": "Уметник албума",
"artist": "Уметник",
- "catalogNum": "Каталошки број",
- "comment": "Коментар",
- "compilation": "Компилација",
- "createdAt": "Датум додавања",
- "date": "Датум снимања",
"duration": "Трајање",
+ "songCount": "Песме",
+ "playCount": "Пуштано",
+ "size": "Величина",
+ "name": "Назив",
+ "libraryName": "Библиотека",
"genre": "Жанр",
+ "compilation": "Компилација",
+ "year": "Година",
+ "date": "Датум снимања",
+ "originalDate": "Оригинално",
+ "releaseDate": "Објављено",
+ "releases": "Издање|||| Издања",
+ "released": "Објављено",
+ "updatedAt": "Ажурирано",
+ "comment": "Коментар",
+ "rating": "Рејтинг",
+ "createdAt": "Датум додавања",
+ "recordLabel": "Издавачка кућа",
+ "catalogNum": "Каталошки број",
+ "releaseType": "Тип",
"grouping": "Груписање",
"media": "Медијум",
"mood": "Расположење",
- "name": "Назив",
- "originalDate": "Оригинално",
- "playCount": "Пуштано",
- "rating": "Рејтинг",
- "recordLabel": "Издавачка кућа",
- "releaseDate": "Објављено",
- "releaseType": "Тип",
- "released": "Објављено",
- "releases": "Издање|||| Издања",
- "size": "Величина",
- "songCount": "Песме",
- "updatedAt": "Ажурирано",
- "year": "Година"
+ "missing": "Недостаје"
},
"actions": {
- "addToPlaylist": "Додај у плејлисту",
- "addToQueue": "Пусти касније",
- "download": "Преузми",
- "info": "Прикажи инфо",
"playAll": "Пусти",
"playNext": "Пусти наредно",
+ "addToQueue": "Пусти касније",
"share": "Дели",
- "shuffle": "Измешај"
+ "shuffle": "Измешај",
+ "addToPlaylist": "Додај у плејлисту",
+ "download": "Преузми",
+ "info": "Прикажи инфо"
},
"lists": {
"all": "Све",
- "mostPlayed": "Најчешће пуштано",
"random": "Насумично",
"recentlyAdded": "Додато недавно",
"recentlyPlayed": "Пуштано недавно",
+ "mostPlayed": "Најчешће пуштано",
"starred": "Омиљено",
"topRated": "Најбоље рангирано"
}
@@ -97,116 +108,136 @@
"artist": {
"name": "Уметник |||| Уметници",
"fields": {
- "albumCount": "Број албума",
- "genre": "Жанр",
"name": "Назив",
+ "albumCount": "Број албума",
+ "songCount": "Број песама",
+ "size": "Величина",
"playCount": "Пуштано",
"rating": "Рејтинг",
+ "genre": "Жанр",
"role": "Улога",
- "size": "Величина",
- "songCount": "Број песама"
+ "missing": "Недостаје"
},
"roles": {
"albumartist": "Уметник албума |||| Уметници албума",
- "arranger": "Аранжер |||| Аранжери",
"artist": "Уметник |||| Уметници",
"composer": "Композитор |||| Композитори",
"conductor": "Диригент |||| Диригенти",
- "director": "Режисер |||| Режисери",
- "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер",
- "engineer": "Инжењер |||| Инжењери",
"lyricist": "Текстописац |||| Текстописци",
- "mixer": "Миксер |||| Миксери",
- "performer": "Извођач |||| Извођачи",
+ "arranger": "Аранжер |||| Аранжери",
"producer": "Продуцент |||| Продуценти",
- "remixer": "Ремиксер |||| Ремиксери"
+ "director": "Режисер |||| Режисери",
+ "engineer": "Инжењер |||| Инжењери",
+ "mixer": "Миксер |||| Миксери",
+ "remixer": "Ремиксер |||| Ремиксери",
+ "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер",
+ "performer": "Извођач |||| Извођачи",
+ "maincredit": "Уметник албума или уметник |||| Уметници албума или уметници"
+ },
+ "actions": {
+ "topSongs": "Најбоље песме",
+ "shuffle": "Измешај",
+ "radio": "Радио"
}
},
"user": {
"name": "Корисник |||| Корисници",
"fields": {
- "changePassword": "Измени лозинку?",
- "createdAt": "Креирана",
- "currentPassword": "Текућа лозинка",
+ "userName": "Корисничко име",
"isAdmin": "Да ли је Админ",
- "lastAccessAt": "Последњи приступ",
"lastLoginAt": "Последња пријава",
- "name": "Назив",
- "newPassword": "Нова лозинка",
- "password": "Лозинка",
- "token": "Жетон",
+ "lastAccessAt": "Последњи приступ",
"updatedAt": "Ажурирано",
- "userName": "Корисничко име"
+ "name": "Назив",
+ "password": "Лозинка",
+ "createdAt": "Креирана",
+ "changePassword": "Измени лозинку?",
+ "currentPassword": "Текућа лозинка",
+ "newPassword": "Нова лозинка",
+ "token": "Жетон",
+ "libraries": "Библиотеке"
},
"helperTexts": {
- "name": "Измене вашег имена ће постати видљиве након следеће пријаве"
+ "name": "Измене вашег имена ће постати видљиве након следеће пријаве",
+ "libraries": "Изаберите одређене библиотеке за овог корисника, или оставите празно да се користе подразумеване библиотеке"
},
"notifications": {
"created": "Корисник креиран",
- "deleted": "Корисник обрисан",
- "updated": "Корисник ажуриран"
+ "updated": "Корисник ажуриран",
+ "deleted": "Корисник обрисан"
+ },
+ "validation": {
+ "librariesRequired": "Барем једна библиотека мора да буде изабрана за кориснике који нису администратори"
},
"message": {
+ "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.",
"clickHereForToken": "Кликните овде да преузмете свој жетон",
- "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон."
+ "selectAllLibraries": "Изабери све библиотеке",
+ "adminAutoLibraries": "Администратори аутоматски имају приступ свим библиотекама"
}
},
"player": {
"name": "Плејер |||| Плејери",
"fields": {
- "client": "Клијент",
- "lastSeen": "Последњи пут виђен",
- "maxBitRate": "Макс. битски проток",
"name": "Назив",
- "reportRealPath": "Пријављуј реалну путању",
- "scrobbleEnabled": "Шаљи скроблове на спољне сервисе",
"transcodingId": "Транскодирање",
- "userName": "Корисничко име"
+ "maxBitRate": "Макс. битски проток",
+ "client": "Клијент",
+ "userName": "Корисничко име",
+ "lastSeen": "Последњи пут виђен",
+ "reportRealPath": "Пријављуј реалну путању",
+ "scrobbleEnabled": "Шаљи скроблове на спољне сервисе"
}
},
"transcoding": {
"name": "Транскодирање |||| Транскодирања",
"fields": {
- "command": "Команда",
- "defaultBitRate": "Подразумевани битски проток",
"name": "Назив",
- "targetFormat": "Циљни формат"
+ "targetFormat": "Циљни формат",
+ "defaultBitRate": "Подразумевани битски проток",
+ "command": "Команда"
}
},
"playlist": {
"name": "Плејлиста |||| Плејлисте",
"fields": {
- "comment": "Коментар",
- "createdAt": "Креирана",
- "duration": "Трајање",
"name": "Назив",
+ "duration": "Трајање",
"ownerName": "Власник",
- "path": "Увоз из",
"public": "Јавна",
+ "updatedAt": "Ажурирано",
+ "createdAt": "Креирана",
"songCount": "Песме",
+ "comment": "Коментар",
"sync": "Ауто-увоз",
- "updatedAt": "Ажурирано"
+ "path": "Увоз из"
},
"actions": {
+ "selectPlaylist": "Изабери плејлисту",
"addNewPlaylist": "Креирај „%{name}”",
"export": "Извези",
- "makePrivate": "Учини приватном",
+ "saveQueue": "Сачувај ред у плејлисту",
"makePublic": "Учини јавном",
- "selectPlaylist": "Изабери плејлисту"
+ "makePrivate": "Учини приватном",
+ "searchOrCreate": "Претражите плејлисте или унесите назив за нову…",
+ "pressEnterToCreate": "Притисните Ентер да креирате нову плејлисту",
+ "removeFromSelection": "Уклони из избора"
},
"message": {
"duplicate_song": "Додај дуплиране песме",
- "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
+ "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?",
+ "noPlaylistsFound": "Нема пронађених плејлиста",
+ "noPlaylists": "Нема доступних плејлиста"
}
},
"radio": {
- "name": "Радио |||| Радији",
+ "name": "Радио |||| Радио-станице",
"fields": {
- "createdAt": "Креирана",
- "homePageUrl": "URL почетне странице",
"name": "Назив",
"streamUrl": "URL тока",
- "updatedAt": "Ажурирано"
+ "homePageUrl": "URL почетне странице",
+ "updatedAt": "Ажурирано",
+ "createdAt": "Креирана"
},
"actions": {
"playNow": "Пусти одмах"
@@ -215,18 +246,18 @@
"share": {
"name": "Дељење |||| Дељења",
"fields": {
- "contents": "Садржај",
- "createdAt": "Креирано",
+ "username": "Поделио",
+ "url": "URL",
"description": "Опис",
"downloadable": "Допушта се преузимање?",
+ "contents": "Садржај",
"expiresAt": "Истиче",
- "format": "Формат",
"lastVisitedAt": "Последњи пут посећено",
+ "visitCount": "Број посета",
+ "format": "Формат",
"maxBitRate": "Макс. битски проток",
"updatedAt": "Ажурирано",
- "url": "URL",
- "username": "Поделио",
- "visitCount": "Број посета"
+ "createdAt": "Креирано"
},
"notifications": {},
"actions": {}
@@ -237,111 +268,246 @@
"fields": {
"path": "Путања",
"size": "Величина",
+ "libraryName": "Библиотека",
"updatedAt": "Нестао дана"
},
"actions": {
- "remove": "Уклони"
+ "remove": "Уклони",
+ "remove_all": "Уклони све"
},
"notifications": {
"removed": "Фајл који недостаје, или више њих, је уклоњен"
}
+ },
+ "library": {
+ "name": "Библиотека |||| Библиотеке",
+ "fields": {
+ "name": "Назив",
+ "path": "Путања",
+ "remotePath": "Удаљена путања",
+ "lastScanAt": "Последње скенирање",
+ "songCount": "Песме",
+ "albumCount": "Албуми",
+ "artistCount": "Уметници",
+ "totalSongs": "Песме",
+ "totalAlbums": "Албуми",
+ "totalArtists": "Уметници",
+ "totalFolders": "Фасцикле",
+ "totalFiles": "Фајлови",
+ "totalMissingFiles": "Фајлови који недостају",
+ "totalSize": "Укупна величина",
+ "totalDuration": "Трајање",
+ "defaultNewUsers": "Подразумевано за нове кориснике",
+ "createdAt": "Креирана",
+ "updatedAt": "Ажурирана"
+ },
+ "sections": {
+ "basic": "Основне информације",
+ "statistics": "Статистика"
+ },
+ "actions": {
+ "scan": "Скенирај библиотеку",
+ "quickScan": "Брзо скенирање",
+ "fullScan": "Комплетно скенирање",
+ "manageUsers": "Управљај приступом корисника",
+ "viewDetails": "Прикажи детаље"
+ },
+ "notifications": {
+ "created": "Библиотека је успешно креирана",
+ "updated": "Библиотека је успешно ажурирана",
+ "deleted": "Библиотека је успешно обрисана",
+ "scanStarted": "Скенирање библиотеке је покренуто",
+ "quickScanStarted": "Брзо скенирање је покренуто",
+ "fullScanStarted": "Комплетно скенирање је покренуто",
+ "scanError": "Грешка при покретању скенирања. Проверите дневнике.",
+ "scanCompleted": "Скенирање библиотеке је завршено"
+ },
+ "validation": {
+ "nameRequired": "Назив библиотеке је обавезан",
+ "pathRequired": "Путања библиотеке је обавезна",
+ "pathNotDirectory": "Путања библиотеке мора да буде фасцикла",
+ "pathNotFound": "Путања библиотеке није пронађена",
+ "pathNotAccessible": "Путања библиотеке није доступна",
+ "pathInvalid": "Неисправна путања библиотеке"
+ },
+ "messages": {
+ "deleteConfirm": "Да ли сте сигурни да желите да обришете ову библиотеку? Ово ће да уклони све повезане податке и приступ корисника.",
+ "scanInProgress": "Скенирање је у току…",
+ "noLibrariesAssigned": "Овом кориснику нема додељених библиотека"
+ }
+ },
+ "plugin": {
+ "name": "Додатак |||| Додаци",
+ "fields": {
+ "id": "ИД",
+ "name": "Назив",
+ "description": "Опис",
+ "version": "Верзија",
+ "author": "Аутор",
+ "website": "Веб-сајт",
+ "permissions": "Дозволе",
+ "enabled": "Омогућено",
+ "status": "Статус",
+ "path": "Путања",
+ "lastError": "Грешка",
+ "hasError": "Грешка",
+ "updatedAt": "Ажурирано",
+ "createdAt": "Инсталирано",
+ "configKey": "Кључ",
+ "configValue": "Вредност",
+ "allUsers": "Дозволи свим корисницима",
+ "selectedUsers": "Изабрани корисници",
+ "allLibraries": "Дозволи све библиотеке",
+ "selectedLibraries": "Изабране библиотеке",
+ "allowWriteAccess": "Дозволи приступ за упис"
+ },
+ "sections": {
+ "status": "Статус",
+ "info": "Информације о додатку",
+ "configuration": "Конфигурација",
+ "manifest": "Манифест",
+ "usersPermission": "Дозволе корисника",
+ "libraryPermission": "Дозволе библиотеке"
+ },
+ "status": {
+ "enabled": "Омогућено",
+ "disabled": "Онемогућено"
+ },
+ "actions": {
+ "enable": "Омогући",
+ "disable": "Онемогући",
+ "disabledDueToError": "Поправите грешку пре омогућавања",
+ "disabledUsersRequired": "Изаберите кориснике пре омогућавања",
+ "disabledLibrariesRequired": "Изаберите библиотеке пре омогућавања",
+ "addConfig": "Додај конфигурацију",
+ "rescan": "Поново скенирај"
+ },
+ "notifications": {
+ "enabled": "Додатак је омогућен",
+ "disabled": "Додатак је онемогућен",
+ "updated": "Додатак је ажуриран",
+ "error": "Грешка при ажурирању додатка"
+ },
+ "validation": {
+ "invalidJson": "Конфигурација мора да буде исправан JSON"
+ },
+ "messages": {
+ "configHelp": "Конфигуришите додатак користећи парове кључ-вредност. Оставите празно ако додатак не захтева конфигурацију.",
+ "configValidationError": "Провера исправности конфигурације није успела:",
+ "schemaRenderError": "Не може да се прикаже образац за конфигурацију. Шема додатка можда није исправна.",
+ "clickPermissions": "Кликните на дозволу за детаље",
+ "noConfig": "Конфигурација није постављена",
+ "allUsersHelp": "Када је омогућено, додатак ће имати приступ свим корисницима, укључујући оне који буду креирани у будућности.",
+ "noUsers": "Нема изабраних корисника",
+ "permissionReason": "Разлог",
+ "usersRequired": "Овај додатак захтева приступ информацијама о корисницима. Изаберите којим корисницима додатак може да приступи, или омогућите „Дозволи свим корисницима”.",
+ "allLibrariesHelp": "Када је омогућено, додатак ће имати приступ свим библиотекама, укључујући оне које буду креиране у будућности.",
+ "noLibraries": "Нема изабраних библиотека",
+ "librariesRequired": "Овај додатак захтева приступ информацијама о библиотекама. Изаберите којим библиотекама додатак може да приступи, или омогућите „Дозволи све библиотеке”.",
+ "allowWriteAccessHelp": "Када је омогућено, додатак може да мења фајлове у фасциклама библиотеке. Подразумевано, додаци имају приступ само за читање.",
+ "requiredHosts": "Потребни хостови"
+ },
+ "placeholders": {
+ "configKey": "кључ",
+ "configValue": "вредност"
+ }
}
},
"ra": {
"auth": {
- "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
- "buttonCreateAdmin": "Креирај админа",
+ "welcome1": "Хвала што сте инсталирали Navidrome!",
+ "welcome2": "За почетак, креирајте админ корисника",
"confirmPassword": "Потврдите лозинку",
- "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите",
- "logout": "Одјави се",
+ "buttonCreateAdmin": "Креирај админа",
+ "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
+ "user_menu": "Профил",
+ "username": "Корисничко име",
"password": "Лозинка",
"sign_in": "Пријави се",
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
- "user_menu": "Профил",
- "username": "Корисничко име",
- "welcome1": "Хвала што сте инсталирали Navidrome!",
- "welcome2": "За почетак, креирајте админ корисника"
+ "logout": "Одјави се",
+ "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите"
},
"validation": {
- "email": "Мора да буде исправна и-мејл адреса",
"invalidChars": "Молимо вас да користите само слова и цифре",
- "maxLength": "Мора да буде %{max} карактера или мање",
- "maxValue": "Мора да буде %{max} или мање",
- "minLength": "Мора да буде барем %{min} карактера",
- "minValue": "Мора да буде барем %{min}",
- "number": "Мора да буде број",
- "oneOf": "Мора да буде једно од: %{options}",
"passwordDoesNotMatch": "Лозинка се не подудара",
- "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
"required": "Неопходно",
+ "minLength": "Мора да буде барем %{min} карактера",
+ "maxLength": "Мора да буде %{max} карактера или мање",
+ "minValue": "Мора да буде барем %{min}",
+ "maxValue": "Мора да буде %{max} или мање",
+ "number": "Мора да буде број",
+ "email": "Мора да буде исправна и-мејл адреса",
+ "oneOf": "Мора да буде једно од: %{options}",
+ "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
"unique": "Мора да буде јединствено",
"url": "Мора да буде исправна URL адреса"
},
"action": {
- "add": "Додај",
"add_filter": "Додај филтер",
+ "add": "Додај",
"back": "Иди назад",
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Откажи",
"clear_input_value": "Обриши вредност",
"clone": "Клонирај",
- "close": "Затвори",
- "close_menu": "Затвори мени",
"confirm": "Потврди",
"create": "Креирај",
"delete": "Обриши",
- "download": "Преузми",
"edit": "Уреди",
- "expand": "Развиј",
"export": "Извези",
"list": "Листа",
- "open_menu": "Отвори мени",
"refresh": "Освежи",
- "remove": "Уклони",
"remove_filter": "Уклони овај филтер",
+ "remove": "Уклони",
"save": "Сачувај",
"search": "Тражи",
- "share": "Дели",
"show": "Прикажи",
- "skip": "Прескочи",
"sort": "Сортирај",
"undo": "Поништи",
- "unselect": "Уклони избор"
+ "expand": "Развиј",
+ "close": "Затвори",
+ "open_menu": "Отвори мени",
+ "close_menu": "Затвори мени",
+ "unselect": "Уклони избор",
+ "skip": "Прескочи",
+ "share": "Дели",
+ "download": "Преузми"
},
"boolean": {
- "false": "Не",
- "true": "Да"
+ "true": "Да",
+ "false": "Не"
},
"page": {
"create": "Креирај %{name}",
"dashboard": "Контролна табла",
"edit": "%{name} #%{id}",
- "empty": "Још увек нема %{name}.",
"error": "Нешто је пошло наопако",
- "invite": "Желите ли да се дода?",
"list": "%{name}",
"loading": "Учитава се",
"not_found": "Није пронађено",
- "show": "%{name} #%{id}"
+ "show": "%{name} #%{id}",
+ "empty": "Још увек нема %{name}.",
+ "invite": "Желите ли да се дода?"
},
"input": {
"file": {
- "upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
- "upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
+ "upload_several": "Превуците фајлове да се отпреме, или кликните да их изаберете.",
+ "upload_single": "Превуците фајл да се отпреми, или кликните да га изаберете."
},
"image": {
- "upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
- "upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
- },
- "password": {
- "toggle_hidden": "Прикажи лозинку",
- "toggle_visible": "Сакриј лозинку"
+ "upload_several": "Превуците слике да се отпреме, или кликните да их изаберете.",
+ "upload_single": "Превуците слику да се отпреми, или кликните да је изаберете."
},
"references": {
"all_missing": "Не могу да се нађу подаци референци.",
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
"single_missing": "Изгледа да придружена референца више није доступна."
+ },
+ "password": {
+ "toggle_visible": "Сакриј лозинку",
+ "toggle_hidden": "Прикажи лозинку"
}
},
"message": {
@@ -357,161 +523,203 @@
"loading": "Страница се учитава, сачекајте мало",
"no": "Не",
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
- "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?",
- "yes": "Да"
+ "yes": "Да",
+ "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?"
},
"navigation": {
- "next": "Наредна",
- "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
"no_results": "Није пронађен ниједан резултат",
- "page_out_from_begin": "Не може да се иде испред странице 1",
- "page_out_from_end": "Не може да се иде након последње странице",
+ "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
+ "page_out_from_end": "Не може да се иде након последње странице",
+ "page_out_from_begin": "Не може да се иде испред странице 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
"page_rows_per_page": "Ставки по страници:",
- "prev": "Претход",
+ "next": "Наредна",
+ "prev": "Претх.",
"skip_nav": "Прескочи на садржај"
},
"notification": {
- "bad_item": "Неисправни елемент",
- "canceled": "Акција је отказана",
+ "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано",
"created": "Елемент је креиран",
- "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
- "http_error": "Грешка у комуникацији са сервером",
- "i18n_error": "Не могу да се учитају преводи за наведени језик",
+ "bad_item": "Неисправни елемент",
"item_doesnt_exist": "Елемент не постоји",
+ "http_error": "Грешка у комуникацији са сервером",
+ "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
+ "i18n_error": "Не могу да се учитају преводи за наведени језик",
+ "canceled": "Акција је отказана",
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
- "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.",
- "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано"
+ "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Колоне за приказ",
- "grid": "Мрежа",
"layout": "Распоред",
+ "grid": "Мрежа",
"table": "Табела"
}
},
"message": {
- "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
- "delete_user_title": "Брисање корисника ’%{name}’",
- "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})",
- "downloadOriginalFormat": "Преузми у оригиналном формату",
- "lastfmLink": "Прочитај још...",
- "lastfmLinkFailure": "Last.fm није могао да се повеже",
- "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
- "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
- "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
- "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
- "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
- "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
- "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
- "noPlaylistsAvailable": "Није доступна ниједна",
+ "uploadCover": "Отпреми омот",
+ "removeCover": "Уклони омот",
+ "coverUploaded": "Омот је ажуриран",
+ "coverRemoved": "Омот је уклоњен",
+ "coverUploadError": "Грешка при отпремању омота",
+ "coverRemoveError": "Грешка при уклањању омота",
"note": "НАПОМЕНА",
+ "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
+ "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.",
+ "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
+ "noSimilarSongsFound": "Нису пронађене сличне песме",
+ "startingInstantMix": "Учитава се инстант микс…",
+ "noTopSongsFound": "Нису пронађене најбоље песме",
+ "noPlaylistsAvailable": "Није доступна ниједна",
+ "delete_user_title": "Брисање корисника ’%{name}’",
+ "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
+ "remove_missing_title": "Уклони фајлове који недостају",
+ "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.",
+ "remove_all_missing_title": "Уклони све фајлове који недостају",
+ "remove_all_missing_content": "Да ли сте сигурни да желите да из базе података уклоните све фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.",
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
+ "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
+ "lastfmLinkFailure": "Last.fm није могао да се повеже",
+ "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
+ "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
+ "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
+ "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
+ "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
+ "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
"openIn": {
"lastfm": "Отвори у Last.fm",
"musicbrainz": "Отвори у MusicBrainz"
},
- "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.",
- "remove_missing_title": "Уклони фајлове који недостају",
+ "lastfmLink": "Прочитај још...",
+ "shareOriginalFormat": "Подели у оригиналном формату",
+ "shareDialogTitle": "Подели %{resource} ’%{name}’",
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
- "shareDialogTitle": "Подели %{resource} ’%{name}’",
- "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
- "shareOriginalFormat": "Подели у оригиналном формату",
"shareSuccess": "URL је копиран у клипборд: %{url}",
- "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
- "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
- "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања."
+ "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
+ "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})",
+ "downloadOriginalFormat": "Преузми у оригиналном формату"
},
"menu": {
- "about": "О",
- "albumList": "Албуми",
"library": "Библиотека",
+ "librarySelector": {
+ "allLibraries": "Све библиотеке (%{count})",
+ "multipleLibraries": "%{selected} од %{total} библиотека",
+ "selectLibraries": "Изабери библиотеке",
+ "none": "Ниједна"
+ },
+ "settings": "Подешавања",
+ "version": "Верзија",
+ "theme": "Тема",
"personal": {
"name": "Лична",
"options": {
+ "theme": "Тема",
+ "language": "Језик",
"defaultView": "Подразумевани поглед",
"desktop_notifications": "Десктоп обавештења",
- "gain": {
- "album": "Користи Album појачање",
- "none": "Искључено",
- "track": "Користи Track појачање"
- },
- "language": "Језик",
"lastfmNotConfigured": "Није подешен Last.fm API-кључ",
"lastfmScrobbling": "Скроблуј на Last.fm",
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
- "preAmp": "ReplayGain претпојачање (dB)",
"replaygain": "ReplayGain режим",
- "theme": "Тема"
+ "preAmp": "ReplayGain претпојачање (dB)",
+ "gain": {
+ "none": "Искључено",
+ "album": "Користи Album појачање",
+ "track": "Користи Track појачање"
+ }
}
},
+ "albumList": "Албуми",
"playlists": "Плејлисте",
- "settings": "Подешавања",
"sharedPlaylists": "Дељене плејлисте",
- "theme": "Тема",
- "version": "Верзија"
+ "about": "О"
},
"player": {
- "clickToDeleteText": "Кликните да обришете %{name}",
- "clickToPauseText": "Кликни за паузирање",
- "clickToPlayText": "Кликни за пуштање",
+ "playListsText": "Ред за пуштање",
+ "openText": "Отвори",
"closeText": "Затвори",
+ "notContentText": "Нема музике",
+ "clickToPlayText": "Кликните за пуштање",
+ "clickToPauseText": "Кликните за паузирање",
+ "nextTrackText": "Наредна нумера",
+ "previousTrackText": "Претходна нумера",
+ "reloadText": "Поново учитај",
+ "volumeText": "Јачина",
+ "toggleLyricText": "Укљ./Искљ. стихове",
+ "toggleMiniModeText": "Умањи",
"destroyText": "Уништи",
"downloadText": "Преузми",
+ "removeAudioListsText": "Обриши аудио листе",
+ "clickToDeleteText": "Кликните да обришете %{name}",
"emptyLyricText": "Нема стихова",
- "nextTrackText": "Наредна нумера",
- "notContentText": "Нема музике",
- "openText": "Отвори",
- "playListsText": "Ред за пуштање",
"playModeText": {
"order": "По редоследу",
"orderLoop": "Понови",
- "shufflePlay": "Измешај",
- "singleLoop": "Понови једну"
- },
- "previousTrackText": "Претходна нумера",
- "reloadText": "Поново учитај",
- "removeAudioListsText": "Обриши аудио листе",
- "toggleLyricText": "Укљ./Искљ. стихове",
- "toggleMiniModeText": "Умањи",
- "volumeText": "Јачина"
+ "singleLoop": "Понови једну",
+ "shufflePlay": "Измешај"
+ }
},
"about": {
"links": {
- "featureRequests": "Захтеви за функцијама",
"homepage": "Почетна страница",
+ "source": "Изворни кôд",
+ "featureRequests": "Захтеви за функције",
+ "lastInsightsCollection": "Последња колекција увида",
"insights": {
"disabled": "Искључено",
"waiting": "Чека се"
- },
- "lastInsightsCollection": "Последња колекција увида",
- "source": "Изворни кôд"
+ }
+ },
+ "tabs": {
+ "about": "О програму",
+ "config": "Конфигурација"
+ },
+ "config": {
+ "configName": "Назив конфигурације",
+ "environmentVariable": "Променљива окружења",
+ "currentValue": "Тренутна вредност",
+ "configurationFile": "Конфигурациони фајл",
+ "exportToml": "Извези конфигурацију (TOML)",
+ "downloadToml": "Преузми конфигурацију (TOML)",
+ "exportSuccess": "Конфигурација је извезена у клипборд у TOML формату",
+ "exportFailed": "Копирање конфигурације није успело",
+ "devFlagsHeader": "Развојне заставице (подложне промени или уклањању)",
+ "devFlagsComment": "Ово су експерименталне поставке и могу бити уклоњене у будућим верзијама"
}
},
"activity": {
- "fullScan": "Комплетно скенирање",
- "quickScan": "Брзо скенирање",
- "serverDown": "ВАН МРЕЖЕ",
- "serverUptime": "Сервер се извршава",
"title": "Активност",
- "totalScanned": "Укупан број скенираних фолдера"
+ "totalScanned": "Укупан број скенираних фолдера",
+ "quickScan": "Брзо скенирање",
+ "fullScan": "Комплетно скенирање",
+ "selectiveScan": "Селективно",
+ "serverUptime": "Сервер се извршава",
+ "serverDown": "ВАН МРЕЖЕ",
+ "scanType": "Последње скенирање",
+ "status": "Грешка скенирања",
+ "elapsedTime": "Протекло време"
+ },
+ "nowPlaying": {
+ "title": "Сада се пушта",
+ "empty": "Ништа се не пушта",
+ "minutesAgo": "Пре %{smart_count} минут |||| Пре %{smart_count} минута"
},
"help": {
"title": "Navidrome пречице",
"hotkeys": {
- "current_song": "Иди на текућу песму",
- "next_song": "Наредна песма",
- "prev_song": "Претходна песма",
"show_help": "Прикажи ову помоћ",
- "toggle_love": "Додај ову нумеру у омиљене",
"toggle_menu": "Укљ./Искљ. бочну траку менија",
"toggle_play": "Пусти / Паузирај",
+ "prev_song": "Претходна песма",
+ "next_song": "Наредна песма",
+ "current_song": "Иди на текућу песму",
+ "vol_up": "Појачај",
"vol_down": "Утишај",
- "vol_up": "Појачај"
+ "toggle_love": "Додај ову нумеру у омиљене"
}
}
}
diff --git a/scanner/controller.go b/scanner/controller.go
index 94248ffd0..175b92e26 100644
--- a/scanner/controller.go
+++ b/scanner/controller.go
@@ -17,7 +17,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
- . "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/pl"
"golang.org/x/time/rate"
)
@@ -38,7 +37,7 @@ func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, br
devExternalScanner: conf.Server.DevExternalScanner,
}
if !c.devExternalScanner {
- c.limiter = P(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate})
+ c.limiter = new(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate})
}
return c
}
diff --git a/scanner/external.go b/scanner/external.go
index 29ca90be6..7f573fe3e 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
@@ -97,8 +97,7 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod
func (s *scannerExternal) wait(cmd *exec.Cmd, out *io.PipeWriter) {
if err := cmd.Wait(); err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
+ if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
_ = out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %w", cmd, exitErr))
} else {
_ = out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", cmd, err))
diff --git a/scanner/metadata_old/metadata_internal_test.go b/scanner/metadata_old/metadata_internal_test.go
index 2d21e07eb..aff1ede9c 100644
--- a/scanner/metadata_old/metadata_internal_test.go
+++ b/scanner/metadata_old/metadata_internal_test.go
@@ -93,7 +93,7 @@ var _ = Describe("Tags", func() {
var t *Tags
BeforeEach(func() {
t = &Tags{Tags: map[string][]string{
- "fbpm": []string{"141.7"},
+ "fbpm": {"141.7"},
}}
})
diff --git a/server/auth.go b/server/auth.go
index a7edaab0a..6a25f1406 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -135,7 +135,6 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error {
log.Warn(ctx, "Creating initial user", "user", username)
- now := time.Now()
caser := cases.Title(language.Und)
initialUser := model.User{
ID: id.NewRandom(),
@@ -144,7 +143,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password
Email: "",
NewPassword: password,
IsAdmin: true,
- LastLoginAt: &now,
+ LastLoginAt: new(time.Now()),
}
err := ds.User(ctx).Put(&initialUser)
if err != nil {
diff --git a/server/e2e/subsonic_multiuser_test.go b/server/e2e/subsonic_multiuser_test.go
index 4a5c35a7e..d8c5d3689 100644
--- a/server/e2e/subsonic_multiuser_test.go
+++ b/server/e2e/subsonic_multiuser_test.go
@@ -60,15 +60,23 @@ var _ = Describe("Multi-User Isolation", Ordered, func() {
})
})
- Describe("getUsers for regular user", func() {
- It("returns only the requesting user's info", func() {
- resp := doReqWithUser(regularUser, "getUsers")
+ Describe("getUsers authorization", func() {
+ It("succeeds for admin user", func() {
+ resp := doReqWithUser(adminUser, "getUsers")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Users).ToNot(BeNil())
Expect(resp.Users.User).To(HaveLen(1))
- Expect(resp.Users.User[0].Username).To(Equal("regular"))
- Expect(resp.Users.User[0].AdminRole).To(BeFalse())
+ Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName))
+ Expect(resp.Users.User[0].AdminRole).To(BeTrue())
+ })
+
+ It("fails for regular user because getUsers is admin-only", func() {
+ resp := doReqWithUser(regularUser, "getUsers")
+
+ Expect(resp.Status).To(Equal(responses.StatusFailed))
+ Expect(resp.Error).ToNot(BeNil())
+ Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail))
})
})
})
diff --git a/server/e2e/subsonic_radio_test.go b/server/e2e/subsonic_radio_test.go
index ce64c31a1..cd778fa79 100644
--- a/server/e2e/subsonic_radio_test.go
+++ b/server/e2e/subsonic_radio_test.go
@@ -46,6 +46,30 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() {
Expect(radioID).ToNot(BeEmpty())
})
+ It("getInternetRadioStations remains available to regular users", func() {
+ resp := doReqWithUser(regularUser, "getInternetRadioStations")
+
+ Expect(resp.Status).To(Equal(responses.StatusOK))
+ Expect(resp.InternetRadioStations).ToNot(BeNil())
+ Expect(resp.InternetRadioStations.Radios).To(HaveLen(1))
+ Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio"))
+ })
+
+ It("createInternetRadioStation requires admin user", func() {
+ resp := doReqWithUser(regularUser, "createInternetRadioStation",
+ "streamUrl", "https://stream.example.com/hacked",
+ "name", "Hacked Radio",
+ )
+
+ Expect(resp.Status).To(Equal(responses.StatusFailed))
+ Expect(resp.Error).ToNot(BeNil())
+ Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail))
+
+ resp = doReq("getInternetRadioStations")
+ Expect(resp.InternetRadioStations.Radios).To(HaveLen(1))
+ Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio"))
+ })
+
It("updateInternetRadioStation modifies the station", func() {
resp := doReq("updateInternetRadioStation",
"id", radioID,
@@ -64,6 +88,35 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() {
Expect(resp.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("https://updated.example.com"))
})
+ It("updateInternetRadioStation requires admin user", func() {
+ resp := doReqWithUser(regularUser, "updateInternetRadioStation",
+ "id", radioID,
+ "streamUrl", "https://stream.example.com/hacked",
+ "name", "Hacked Radio",
+ )
+
+ Expect(resp.Status).To(Equal(responses.StatusFailed))
+ Expect(resp.Error).ToNot(BeNil())
+ Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail))
+
+ resp = doReq("getInternetRadioStations")
+ Expect(resp.InternetRadioStations.Radios).To(HaveLen(1))
+ Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio"))
+ Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2"))
+ })
+
+ It("deleteInternetRadioStation requires admin user", func() {
+ resp := doReqWithUser(regularUser, "deleteInternetRadioStation", "id", radioID)
+
+ Expect(resp.Status).To(Equal(responses.StatusFailed))
+ Expect(resp.Error).ToNot(BeNil())
+ Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail))
+
+ resp = doReq("getInternetRadioStations")
+ Expect(resp.InternetRadioStations.Radios).To(HaveLen(1))
+ Expect(resp.InternetRadioStations.Radios[0].ID).To(Equal(radioID))
+ })
+
It("deleteInternetRadioStation removes it", func() {
resp := doReq("deleteInternetRadioStation", "id", radioID)
diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go
index 1b78bb93e..daa918d00 100644
--- a/server/nativeapi/artists.go
+++ b/server/nativeapi/artists.go
@@ -45,8 +45,7 @@ func (api *Router) uploadArtistImage() http.HandlerFunc {
return err
}
ar.UploadedImage = filename
- now := time.Now()
- ar.UpdatedAt = &now
+ ar.UpdatedAt = new(time.Now())
return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at")
})
}
@@ -65,8 +64,7 @@ func (api *Router) deleteArtistImage() http.HandlerFunc {
return err
}
ar.UploadedImage = ""
- now := time.Now()
- ar.UpdatedAt = &now
+ ar.UpdatedAt = new(time.Now())
return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at")
})
}
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/nativeapi/queue_test.go b/server/nativeapi/queue_test.go
index ef971ee68..0aad09718 100644
--- a/server/nativeapi/queue_test.go
+++ b/server/nativeapi/queue_test.go
@@ -9,7 +9,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -32,7 +31,7 @@ var _ = Describe("Queue Endpoints", func() {
Describe("POST /queue", func() {
It("saves the queue", func() {
- payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))}
+ payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1), Position: new(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
ctx := request.WithUser(req.Context(), user)
@@ -50,7 +49,7 @@ var _ = Describe("Queue Endpoints", func() {
})
It("saves an empty queue", func() {
- payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))}
+ payload := updateQueuePayload{Ids: new([]string{}), Current: new(0), Position: new(int64(0))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -63,7 +62,7 @@ var _ = Describe("Queue Endpoints", func() {
})
It("returns bad request for invalid current index (negative)", func() {
- payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))}
+ payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(-1), Position: new(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -75,7 +74,7 @@ var _ = Describe("Queue Endpoints", func() {
})
It("returns bad request for invalid current index (too large)", func() {
- payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))}
+ payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(2), Position: new(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -97,7 +96,7 @@ var _ = Describe("Queue Endpoints", func() {
It("returns internal server error when store fails", func() {
repo.Err = true
- payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))}
+ payload := updateQueuePayload{Ids: new([]string{"s1"}), Current: new(0), Position: new(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -166,7 +165,7 @@ var _ = Describe("Queue Endpoints", func() {
Describe("PUT /queue", func() {
It("updates the queue fields", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}}
- payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))}
+ payload := updateQueuePayload{Current: new(2), Position: new(int64(20))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
ctx := request.WithUser(req.Context(), user)
@@ -184,7 +183,7 @@ var _ = Describe("Queue Endpoints", func() {
It("updates only ids", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1}
- payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})}
+ payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -198,7 +197,7 @@ var _ = Describe("Queue Endpoints", func() {
It("updates ids and current", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID}
- payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)}
+ payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1)}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -213,7 +212,7 @@ var _ = Describe("Queue Endpoints", func() {
It("returns bad request when new ids invalidate current", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2}
- payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})}
+ payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -225,7 +224,7 @@ var _ = Describe("Queue Endpoints", func() {
It("returns bad request when current out of bounds", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}}
- payload := updateQueuePayload{Current: gg.P(3)}
+ payload := updateQueuePayload{Current: new(3)}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -246,7 +245,7 @@ var _ = Describe("Queue Endpoints", func() {
It("returns internal server error when store fails", func() {
repo.Err = true
- payload := updateQueuePayload{Position: gg.P(int64(10))}
+ payload := updateQueuePayload{Position: new(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go
index 7d5a836b3..c7b8a4d4f 100644
--- a/server/public/handle_streams.go
+++ b/server/public/handle_streams.go
@@ -7,7 +7,7 @@ import (
"time"
"github.com/navidrome/navidrome/core/auth"
- "github.com/navidrome/navidrome/core/stream"
+ streampkg "github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
@@ -48,10 +48,15 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
return
}
- stream, err := pub.streamer.NewStream(ctx, mf, stream.Request{
+ stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{
Format: info.format, BitRate: info.bitrate,
})
if err != nil {
+ if errors.Is(err, streampkg.ErrTooManyTranscodes) {
+ w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds))
+ http.Error(w, "too many concurrent transcodes, please retry shortly", http.StatusTooManyRequests)
+ return
+ }
log.Error(ctx, "Error starting shared stream", err)
http.Error(w, "invalid request", http.StatusInternalServerError)
return
diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go
index 222d5ef1a..f43d75a26 100644
--- a/server/public/handle_streams_test.go
+++ b/server/public/handle_streams_test.go
@@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
- . "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -89,7 +88,7 @@ var _ = Describe("encodeMediafileShare", func() {
})
It("includes the share ID in the token", func() {
- exp := P(time.Now().Add(time.Hour))
+ exp := new(time.Now().Add(time.Hour))
s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp}
token := encodeMediafileShare(s, "mf-999")
info, err := decodeStreamInfo(token)
@@ -164,8 +163,7 @@ var _ = Describe("handleStream", func() {
It("returns 410 when share has been set to expired", func() {
shareRepo.ID = "share123"
- expired := time.Now().Add(-time.Hour)
- shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired}
+ shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))}
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
token, _ := auth.CreatePublicToken(claims)
diff --git a/server/subsonic/api.go b/server/subsonic/api.go
index 1ca364449..f39dec009 100644
--- a/server/subsonic/api.go
+++ b/server/subsonic/api.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"regexp"
+ "strconv"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
@@ -171,12 +172,12 @@ func (api *Router) routes() http.Handler {
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getUser", api.GetUser)
- h(r, "getUsers", api.GetUsers)
+ h(r.With(adminOnly), "getUsers", api.GetUsers)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getScanStatus", api.GetScanStatus)
- h(r, "startScan", api.StartScan)
+ h(r.With(adminOnly), "startScan", api.StartScan)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
@@ -195,10 +196,13 @@ func (api *Router) routes() http.Handler {
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
- h(r, "createInternetRadioStation", api.CreateInternetRadio)
- h(r, "deleteInternetRadioStation", api.DeleteInternetRadio)
h(r, "getInternetRadioStations", api.GetInternetRadios)
- h(r, "updateInternetRadioStation", api.UpdateInternetRadio)
+ r.Group(func(r chi.Router) {
+ r.Use(adminOnly)
+ h(r, "createInternetRadioStation", api.CreateInternetRadio)
+ h(r, "deleteInternetRadioStation", api.DeleteInternetRadio)
+ h(r, "updateInternetRadioStation", api.UpdateInternetRadio)
+ })
})
if conf.Server.EnableSharing {
r.Group(func(r chi.Router) {
@@ -301,6 +305,8 @@ func mapToSubsonicError(err error) subError {
err = newError(responses.ErrorDataNotFound, "data not found")
case errors.Is(err, model.ErrNotAuthorized):
err = newError(responses.ErrorAuthorizationFail)
+ case errors.Is(err, stream.ErrTooManyTranscodes):
+ err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly")
default:
err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err))
}
@@ -310,15 +316,31 @@ func mapToSubsonicError(err error) subError {
}
func sendError(w http.ResponseWriter, r *http.Request, err error) {
+ if errors.Is(err, stream.ErrTooManyTranscodes) {
+ w.Header().Set("Retry-After", strconv.Itoa(stream.RetryAfterSeconds))
+ sendResponseWithStatus(w, r, errorResponse(err), http.StatusTooManyRequests)
+ return
+ }
+ sendResponse(w, r, errorResponse(err))
+}
+
+func errorResponse(err error) *responses.Subsonic {
subErr := mapToSubsonicError(err)
response := newResponse()
response.Status = responses.StatusFailed
response.Error = &responses.Error{Code: subErr.code, Message: subErr.Error()}
-
- sendResponse(w, r, response)
+ return response
}
func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) {
+ sendResponseWithStatus(w, r, payload, 0)
+}
+
+// sendResponseWithStatus writes the response body in the format requested by
+// the client. When status is non-zero, WriteHeader is called with that code
+// before the body is written; callers that need to set additional headers
+// (e.g. Retry-After) must set them before calling.
+func sendResponseWithStatus(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic, status int) {
p := req.Params(r)
f, _ := p.String("f")
var response []byte
@@ -353,6 +375,9 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
sendError(w, r, err)
return
}
+ if status != 0 {
+ w.WriteHeader(status)
+ }
if payload.Status == responses.StatusOK {
if log.IsGreaterOrEqualTo(log.LevelTrace) {
@@ -375,6 +400,10 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
}
if _, err := w.Write(response); err != nil { //nolint:gosec
- log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
+ if log.IsGreaterOrEqualTo(log.LevelTrace) {
+ log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
+ } else {
+ log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, err)
+ }
}
}
diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go
index f3053c8af..a1b66925a 100644
--- a/server/subsonic/api_test.go
+++ b/server/subsonic/api_test.go
@@ -3,13 +3,14 @@ package subsonic
import (
"encoding/json"
"encoding/xml"
+ "fmt"
"math"
"net/http"
"net/http/httptest"
"strings"
+ "github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/server/subsonic/responses"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"golang.org/x/net/context"
@@ -136,7 +137,7 @@ var _ = Describe("sendResponse", func() {
It("should return a fail response", func() {
payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}}
// An +Inf value will cause an error when marshalling to JSON
- payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))}
+ payload.Song.ReplayGain = responses.ReplayGain{TrackGain: new(math.Inf(1))}
q := r.URL.Query()
q.Add("f", "json")
r.URL.RawQuery = q.Encode()
@@ -153,6 +154,24 @@ var _ = Describe("sendResponse", func() {
})
})
+ It("responds with HTTP 429 and Retry-After when the transcode limiter rejects", func() {
+ w = httptest.NewRecorder()
+ r = httptest.NewRequest("GET", "/rest/stream", nil)
+
+ sendError(w, r, fmt.Errorf("rejected: %w", stream.ErrTooManyTranscodes))
+
+ Expect(w.Code).To(Equal(http.StatusTooManyRequests))
+ Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty())
+
+ var subsonicResponse responses.Subsonic
+ err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(subsonicResponse.Status).To(Equal(responses.StatusFailed))
+ Expect(subsonicResponse.Error).ToNot(BeNil())
+ Expect(subsonicResponse.Error.Code).To(Equal(responses.ErrorGeneric))
+ Expect(subsonicResponse.Error.Message).To(ContainSubstring("transcode"))
+ })
+
It("updates status pointer when an error occurs", func() {
pointer := int32(0)
diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go
index 5b9c4f3c9..817238aaf 100644
--- a/server/subsonic/browsing.go
+++ b/server/subsonic/browsing.go
@@ -256,8 +256,7 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) {
}
response := newResponse()
- child := childFromMediaFile(ctx, *mf)
- response.Song = &child
+ response.Song = new(childFromMediaFile(ctx, *mf))
return response, nil
}
diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go
index 6a14aa4aa..565b064a2 100644
--- a/server/subsonic/helpers.go
+++ b/server/subsonic/helpers.go
@@ -18,7 +18,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
- . "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/number"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
@@ -217,7 +216,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
child.Path = fakePath(mf)
}
child.DiscNumber = int32(mf.DiscNumber)
- child.Created = P(mf.BirthTime)
+ child.Created = new(mf.BirthTime)
child.AlbumId = mf.AlbumID
child.ArtistId = mf.ArtistID
child.Type = "music"
@@ -266,6 +265,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
@@ -345,7 +345,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
child.Genre = al.Genre
child.CoverArt = al.CoverArtID().String()
- child.Created = P(albumCreatedAt(al))
+ child.Created = new(albumCreatedAt(al))
child.Parent = al.AlbumArtistID
child.ArtistId = al.AlbumArtistID
child.Duration = int32(al.Duration)
@@ -375,6 +375,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
@@ -440,7 +441,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
dir.PlayCount = album.PlayCount
dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear))
dir.Genre = album.Genre
- dir.Created = P(albumCreatedAt(album))
+ dir.Created = albumCreatedAt(album)
if album.Starred {
dir.Starred = album.StarredAt
}
diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go
index abf6116f3..2ae6eb28e 100644
--- a/server/subsonic/helpers_test.go
+++ b/server/subsonic/helpers_test.go
@@ -576,30 +576,27 @@ var _ = Describe("helpers", func() {
t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
al := model.Album{ID: "a1", Name: "A", CreatedAt: t}
dir := buildAlbumID3(ctx, al)
- Expect(dir.Created).ToNot(BeNil())
- Expect(*dir.Created).To(Equal(t))
+ Expect(dir.Created).To(Equal(t))
})
It("falls back to UpdatedAt when CreatedAt is zero", func() {
updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC)
al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated}
dir := buildAlbumID3(ctx, al)
- Expect(dir.Created).ToNot(BeNil())
- Expect(*dir.Created).To(Equal(updated))
+ Expect(dir.Created).To(Equal(updated))
})
It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() {
imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC)
al := model.Album{ID: "a3", Name: "A", ImportedAt: imported}
dir := buildAlbumID3(ctx, al)
- Expect(dir.Created).ToNot(BeNil())
- Expect(*dir.Created).To(Equal(imported))
+ Expect(dir.Created).To(Equal(imported))
})
- It("never leaves Created nil even when all timestamps are zero", func() {
+ It("leaves Created as zero time when all timestamps are zero", func() {
al := model.Album{ID: "a4", Name: "A"}
dir := buildAlbumID3(ctx, al)
- Expect(dir.Created).ToNot(BeNil())
+ Expect(dir.Created.IsZero()).To(BeTrue())
})
})
diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go
index bac27f821..e6f64456d 100644
--- a/server/subsonic/library_scanning.go
+++ b/server/subsonic/library_scanning.go
@@ -40,10 +40,6 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
return nil, newError(responses.ErrorGeneric, "Internal error")
}
- if !loggedUser.IsAdmin {
- return nil, newError(responses.ErrorAuthorizationFail)
- }
-
p := req.Params(r)
fullScan := p.BoolOr("fullScan", false)
diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go
index c62c156bc..771fc3352 100644
--- a/server/subsonic/library_scanning_test.go
+++ b/server/subsonic/library_scanning_test.go
@@ -23,29 +23,6 @@ var _ = Describe("LibraryScanning", func() {
})
Describe("StartScan", func() {
- It("requires admin authentication", func() {
- // Create non-admin user
- ctx := request.WithUser(context.Background(), model.User{
- ID: "user-id",
- IsAdmin: false,
- })
-
- // Create request
- r := httptest.NewRequest("GET", "/rest/startScan", nil)
- r = r.WithContext(ctx)
-
- // Call endpoint
- response, err := api.StartScan(r)
-
- // Should return authorization error
- Expect(err).To(HaveOccurred())
- Expect(response).To(BeNil())
- var subErr subError
- ok := errors.As(err, &subErr)
- Expect(ok).To(BeTrue())
- Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail))
- })
-
It("triggers a full scan with no parameters", func() {
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go
index 0bde18121..68c9cf1e6 100644
--- a/server/subsonic/media_retrieval_test.go
+++ b/server/subsonic/media_retrieval_test.go
@@ -363,7 +363,6 @@ var _ = Describe("MediaRetrievalController", func() {
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
- offset := int64(-100)
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
@@ -381,7 +380,7 @@ var _ = Describe("MediaRetrievalController", func() {
Value: "You know the rules and so do I",
},
},
- Offset: &offset,
+ Offset: new(int64(-100)),
},
},
})
diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go
index 5832bb1de..837852d18 100644
--- a/server/subsonic/middlewares.go
+++ b/server/subsonic/middlewares.go
@@ -155,6 +155,23 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
}
}
+func adminOnly(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ loggedUser, ok := request.UserFrom(r.Context())
+ if !ok {
+ sendError(w, r, newError(responses.ErrorGeneric, "Internal error"))
+ return
+ }
+
+ if !loggedUser.IsAdmin {
+ sendError(w, r, newError(responses.ErrorAuthorizationFail))
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
valid := false
diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go
index aba14a0aa..3f8c07a56 100644
--- a/server/subsonic/middlewares_test.go
+++ b/server/subsonic/middlewares_test.go
@@ -308,6 +308,36 @@ var _ = Describe("Middlewares", func() {
})
})
+ Describe("AdminOnly", func() {
+ It("passes admin users", func() {
+ r := newGetRequest()
+ r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true}))
+
+ adminOnly(next).ServeHTTP(w, r)
+
+ Expect(next.called).To(BeTrue())
+ })
+
+ It("rejects non-admin users", func() {
+ r := newGetRequest()
+ r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false}))
+
+ adminOnly(next).ServeHTTP(w, r)
+
+ Expect(w.Body.String()).To(ContainSubstring(`code="50"`))
+ Expect(next.called).To(BeFalse())
+ })
+
+ It("returns an internal error when user is missing from context", func() {
+ r := newGetRequest()
+
+ adminOnly(next).ServeHTTP(w, r)
+
+ Expect(w.Body.String()).To(ContainSubstring(`code="0"`))
+ Expect(next.called).To(BeFalse())
+ })
+ })
+
Describe("GetPlayer", func() {
var mockedPlayers *mockPlayers
var r *http.Request
diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go
index a8c3da68c..7101f9f15 100644
--- a/server/subsonic/playlists.go
+++ b/server/subsonic/playlists.go
@@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
- . "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -169,7 +168,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso
pls.Readonly = true
if p.EvaluatedAt != nil {
- pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay))
+ pls.ValidUntil = new(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay))
}
} else {
user, ok := request.UserFrom(ctx)
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..bff0bd20c 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
@@ -8,7 +8,9 @@
"id": "1",
"name": "album",
"artist": "artist",
+ "songCount": 0,
"duration": 292,
+ "created": "0001-01-01T00:00:00Z",
"genre": "rock",
"userRating": 4,
"genres": [
@@ -165,7 +167,11 @@
}
],
"displayComposer": "composer 1 \u0026 composer 2",
- "explicitStatus": "clean"
+ "explicitStatus": "clean",
+ "groupings": [
+ "Soundtrack",
+ "Live"
+ ]
},
{
"id": "2",
@@ -210,7 +216,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..16a7748aa 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
@@ -1,5 +1,5 @@
-
+
@@ -32,6 +32,8 @@
+ Soundtrack
+ Live
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON
index 14e96939e..030502618 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON
+++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON
@@ -7,6 +7,8 @@
"album": {
"id": "",
"name": "",
- "duration": 0
+ "songCount": 0,
+ "duration": 0,
+ "created": "0001-01-01T00:00:00Z"
}
}
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML
index 868265347..0d3882080 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML
+++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML
@@ -1,3 +1,3 @@
-
+
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON
index 446368fa5..d3964663b 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON
+++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON
@@ -7,7 +7,9 @@
"album": {
"id": "",
"name": "",
+ "songCount": 0,
"duration": 0,
+ "created": "0001-01-01T00:00:00Z",
"userRating": 0,
"genres": [],
"musicBrainzId": "",
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML
index 868265347..0d3882080 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML
+++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML
@@ -1,3 +1,3 @@
-
+
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 b8b5a03cd..7e41a1daa 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 {
@@ -250,10 +251,10 @@ type AlbumID3 struct {
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
- SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
+ SongCount int32 `xml:"songCount,attr" json:"songCount"`
Duration int32 `xml:"duration,attr" json:"duration"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
- Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
+ Created time.Time `xml:"created,attr" json:"created"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go
index ee98a3daa..3166df875 100644
--- a/server/subsonic/responses/responses_test.go
+++ b/server/subsonic/responses/responses_test.go
@@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/navidrome/navidrome/server/subsonic/responses"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -94,11 +93,10 @@ var _ = Describe("Responses", func() {
Context("with data", func() {
BeforeEach(func() {
artists := make([]Artist, 1)
- t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
artists[0] = Artist{
Id: "111",
Name: "aaa",
- Starred: &t,
+ Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)),
UserRating: 3,
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
}
@@ -133,11 +131,10 @@ var _ = Describe("Responses", func() {
Context("with data", func() {
BeforeEach(func() {
artists := make([]ArtistID3, 1)
- t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
artists[0] = ArtistID3{
Id: "111",
Name: "aaa",
- Starred: &t,
+ Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)),
UserRating: 3,
AlbumCount: 2,
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
@@ -158,11 +155,10 @@ var _ = Describe("Responses", func() {
Context("with OpenSubsonic data", func() {
BeforeEach(func() {
artists := make([]ArtistID3, 1)
- t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
artists[0] = ArtistID3{
Id: "111",
Name: "aaa",
- Starred: &t,
+ Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)),
UserRating: 3,
AlbumCount: 2,
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
@@ -211,12 +207,11 @@ var _ = Describe("Responses", func() {
BeforeEach(func() {
response.Directory = &Directory{Id: "1", Name: "N"}
child := make([]Child, 2)
- t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
child[0] = Child{
Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1,
Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac",
Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3",
- Duration: 146, BitRate: 320, Starred: &t,
+ Duration: 146, BitRate: 320, Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)),
}
child[0].OpenSubsonicChild = &OpenSubsonicChild{
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
@@ -224,7 +219,8 @@ var _ = Describe("Responses", func() {
Isrc: []string{"ISRC-1", "ISRC-2"},
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
Moods: []string{"happy", "sad"},
- 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)},
+ Groupings: []string{"Soundtrack", "Live"},
+ ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)},
DisplayArtist: "artist 1 & artist 2",
Artists: []ArtistID3Ref{
{Id: "1", Name: "artist1"},
@@ -245,7 +241,7 @@ var _ = Describe("Responses", func() {
ExplicitStatus: "clean",
}
child[1].OpenSubsonicChild = &OpenSubsonicChild{
- ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)},
+ ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)},
}
response.Directory.Child = child
})
@@ -320,7 +316,8 @@ var _ = Describe("Responses", func() {
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song",
Isrc: []string{"ISRC-1"},
Moods: []string{"happy", "sad"},
- 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)},
+ Groupings: []string{"Soundtrack", "Live"},
+ ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)},
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
DisplayArtist: "artist1 & artist2",
Artists: []ArtistID3Ref{
@@ -340,7 +337,7 @@ var _ = Describe("Responses", func() {
ExplicitStatus: "clean",
}
songs[1].OpenSubsonicChild = &OpenSubsonicChild{
- ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)},
+ ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)},
}
response.AlbumWithSongsID3.AlbumID3 = album
response.AlbumWithSongsID3.Song = songs
@@ -424,6 +421,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"},
@@ -801,7 +799,7 @@ var _ = Describe("Responses", func() {
Context("with data", func() {
BeforeEach(func() {
response.PlayQueueByIndex.Username = "user1"
- response.PlayQueueByIndex.CurrentIndex = gg.P(0)
+ response.PlayQueueByIndex.CurrentIndex = new(0)
response.PlayQueueByIndex.Position = 243
response.PlayQueueByIndex.Changed = time.Time{}
response.PlayQueueByIndex.ChangedBy = "a_client"
diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go
index 9cc8d7097..a9ccfdca4 100644
--- a/server/subsonic/sharing.go
+++ b/server/subsonic/sharing.go
@@ -58,12 +58,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) {
}
description, _ := p.String("description")
- expires := p.TimeOr("expires", time.Time{})
-
repo := api.share.NewRepository(r.Context())
share := &model.Share{
Description: description,
- ExpiresAt: &expires,
+ ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
}
@@ -90,13 +88,11 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) {
}
description, _ := p.String("description")
- expires := p.TimeOr("expires", time.Time{})
-
repo := api.share.NewRepository(r.Context())
share := &model.Share{
ID: id,
Description: description,
- ExpiresAt: &expires,
+ ExpiresAt: new(p.TimeOr("expires", time.Time{})),
}
err = repo.(rest.Persistable).Update(id, share)
diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go
index b49af2b24..28b4585f0 100644
--- a/server/subsonic/stream.go
+++ b/server/subsonic/stream.go
@@ -1,12 +1,15 @@
package subsonic
import (
+ "context"
+ "errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -119,14 +122,28 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
return nil, err
case *model.Album:
setHeaders(v.Name)
- return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)
+ return nil, handleArchiveErr(ctx, id, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w))
case *model.Artist:
setHeaders(v.Name)
- return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)
+ return nil, handleArchiveErr(ctx, id, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w))
case *model.Playlist:
setHeaders(v.Name)
- return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)
+ return nil, handleArchiveErr(ctx, id, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w))
default:
return nil, model.ErrNotFound
}
}
+
+// handleArchiveErr swallows ErrTooManyTranscodes from archive downloads so the
+// outer error handler does not try to write a 429 onto a response whose status
+// and Content-Disposition have already been flushed. The archive ends up with
+// the tracks that were written before the rejection (the rejected track and
+// any following ones are omitted); the server-side log is the unambiguous
+// signal operators can act on.
+func handleArchiveErr(ctx context.Context, id string, err error) error {
+ if errors.Is(err, stream.ErrTooManyTranscodes) {
+ log.Warn(ctx, "Archive download finalized early: transcode cap reached", "id", id, err)
+ return nil
+ }
+ return err
+}
diff --git a/server/subsonic/users.go b/server/subsonic/users.go
index 8b7406b60..acf8de3e8 100644
--- a/server/subsonic/users.go
+++ b/server/subsonic/users.go
@@ -46,8 +46,7 @@ func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) {
return nil, newError(responses.ErrorAuthorizationFail)
}
response := newResponse()
- user := buildUserResponse(loggedUser)
- response.User = &user
+ response.User = new(buildUserResponse(loggedUser))
return response, nil
}
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/tests/mock_user_repo.go b/tests/mock_user_repo.go
index cc05829f6..7c7dadbc4 100644
--- a/tests/mock_user_repo.go
+++ b/tests/mock_user_repo.go
@@ -7,7 +7,6 @@ import (
"time"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/utils/gg"
)
func CreateMockUserRepo() *MockedUserRepo {
@@ -84,7 +83,7 @@ func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, err
func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {
- usr.LastLoginAt = gg.P(time.Now())
+ usr.LastLoginAt = new(time.Now())
return nil
}
}
@@ -94,7 +93,7 @@ func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
func (u *MockedUserRepo) UpdateLastAccessAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {
- usr.LastAccessAt = gg.P(time.Now())
+ usr.LastAccessAt = new(time.Now())
return nil
}
}
diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx
index e2070deea..c3d795b26 100644
--- a/ui/src/audioplayer/Player.jsx
+++ b/ui/src/audioplayer/Player.jsx
@@ -272,18 +272,6 @@ const Player = () => {
}
}, [])
- const onAudioSeeked = useCallback(
- (info) => {
- if (!info.isRadio && currentTrackId) {
- const posMs = Math.floor(info.currentTime * 1000)
- lastPositionMsRef.current = posMs
- const state = audioInstance?.paused ? 'paused' : 'playing'
- subsonic.reportPlayback(currentTrackId, posMs, state)
- }
- },
- [currentTrackId, audioInstance],
- )
-
const onAudioVolumeChange = useCallback(
// sqrt to compensate for the logarithmic volume
(volume) => dispatch(setVolume(Math.sqrt(volume))),
@@ -436,6 +424,35 @@ const Player = () => {
}
}, [isMobilePlayer, audioInstance])
+ // Report every seek (including programmatic ones the library does not surface
+ // via onAudioSeeked, e.g. restartCurrentOnPrev). Debounce coalesces drag
+ // bursts into one report at the final position.
+ useEffect(() => {
+ if (!audioInstance) return
+ let timer = null
+ const flush = () => {
+ timer = null
+ if (
+ !currentTrackIdRef.current ||
+ playerStateRef.current?.current?.isRadio
+ ) {
+ return
+ }
+ const posMs = Math.floor((audioInstance.currentTime || 0) * 1000)
+ const state = audioInstance.paused ? 'paused' : 'playing'
+ subsonic.reportPlayback(currentTrackIdRef.current, posMs, state)
+ }
+ const handleSeeked = () => {
+ if (timer) clearTimeout(timer)
+ timer = setTimeout(flush, 250)
+ }
+ audioInstance.addEventListener('seeked', handleSeeked)
+ return () => {
+ if (timer) clearTimeout(timer)
+ audioInstance.removeEventListener('seeked', handleSeeked)
+ }
+ }, [audioInstance])
+
return (
{
onAudioListsChange={onAudioListsChange}
onAudioVolumeChange={onAudioVolumeChange}
onAudioProgress={onAudioProgress}
- onAudioSeeked={onAudioSeeked}
onAudioPlay={onAudioPlay}
onAudioPlayTrackChange={onAudioPlayTrackChange}
onAudioPause={onAudioPause}
diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx
index 67018d2bb..c8e07328f 100644
--- a/ui/src/personal/LastfmScrobbleToggle.jsx
+++ b/ui/src/personal/LastfmScrobbleToggle.jsx
@@ -13,21 +13,10 @@ import { baseUrl, openInNewTab } from '../utils'
import { httpClient } from '../dataProvider'
const Progress = (props) => {
- const { setLinked, setCheckingLink, apiKey } = props
+ const { setLinked, setCheckingLink, openedTab } = props
const notify = useNotify()
let linkCheckDelay = 2000
let linkChecks = 30
- const openedTab = useRef()
-
- useEffect(() => {
- const callbackEndpoint = baseUrl(
- `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}`,
- )
- const callbackUrl = `${window.location.origin}${callbackEndpoint}`
- openedTab.current = openInNewTab(
- `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}`,
- )
- }, [apiKey])
const endChecking = (success) => {
linkCheckDelay = null
@@ -76,6 +65,7 @@ export const LastfmScrobbleToggle = (props) => {
const [linked, setLinked] = useState(null)
const [checkingLink, setCheckingLink] = useState(false)
const [apiKey, setApiKey] = useState(false)
+ const openedTab = useRef()
useEffect(() => {
httpClient('/api/lastfm/link')
@@ -88,9 +78,42 @@ export const LastfmScrobbleToggle = (props) => {
})
}, [setLinked, setApiKey])
+ const startLink = () => {
+ // Open the tab synchronously so popup blockers attribute it to the click.
+ let tab
+ try {
+ tab = openInNewTab('about:blank')
+ } catch {
+ notify('message.lastfmLinkFailure', 'warning')
+ return
+ }
+ openedTab.current = tab
+ setCheckingLink(true)
+ httpClient('/api/lastfm/link')
+ .then((response) => {
+ const linkToken = response.json.linkToken
+ if (!linkToken) {
+ tab?.close()
+ notify('message.lastfmLinkFailure', 'warning')
+ setCheckingLink(false)
+ return
+ }
+ const callbackEndpoint = baseUrl(
+ `/api/lastfm/link/callback?uid=${encodeURIComponent(linkToken)}`,
+ )
+ const callbackUrl = `${window.location.origin}${callbackEndpoint}`
+ tab.location.href = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}`
+ })
+ .catch(() => {
+ tab?.close()
+ notify('message.lastfmLinkFailure', 'warning')
+ setCheckingLink(false)
+ })
+ }
+
const toggleScrobble = () => {
if (!linked) {
- setCheckingLink(true)
+ startLink()
} else {
httpClient('/api/lastfm/link', { method: 'DELETE' })
.then(() => {
@@ -121,7 +144,7 @@ export const LastfmScrobbleToggle = (props) => {
)}
{!apiKey && (
diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js
index 74f7d3fd4..55205baf3 100644
--- a/ui/src/themes/amusic.js
+++ b/ui/src/themes/amusic.js
@@ -192,6 +192,11 @@ export default {
paddingBottom: '1rem',
},
},
+ RaConfirm: {
+ confirmPrimary: {
+ color: '#fff',
+ },
+ },
RaDeleteWithConfirmButton: {
deleteButton: {
color: '#fff !important',
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/cached_http_client.go b/utils/cache/cached_http_client.go
index 94d33100b..4eed243dd 100644
--- a/utils/cache/cached_http_client.go
+++ b/utils/cache/cached_http_client.go
@@ -75,8 +75,7 @@ func (c *HTTPClient) serializeReq(req *http.Request) string {
}
if req.Body != nil {
bodyData, _ := io.ReadAll(req.Body)
- bodyStr := base64.StdEncoding.EncodeToString(bodyData)
- data.Body = &bodyStr
+ data.Body = new(base64.StdEncoding.EncodeToString(bodyData))
}
j, _ := json.Marshal(&data)
return string(j)
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())
})
diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go
index 2f0ff4764..eb3c99995 100644
--- a/utils/cache/simple_cache.go
+++ b/utils/cache/simple_cache.go
@@ -9,7 +9,6 @@ import (
"time"
"github.com/jellydator/ttlcache/v3"
- . "github.com/navidrome/navidrome/utils/gg"
)
type SimpleCache[K comparable, V any] interface {
@@ -119,7 +118,7 @@ func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Dur
func (c *simpleCache[K, V]) evictExpired() {
if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) {
c.data.DeleteExpired()
- c.evictionDeadline.Store(P(time.Now().Add(evictionTimeout)))
+ c.evictionDeadline.Store(new(time.Now().Add(evictionTimeout)))
}
}
diff --git a/utils/chrono/meter.go b/utils/chrono/meter.go
index 7b4786ed5..2a249f455 100644
--- a/utils/chrono/meter.go
+++ b/utils/chrono/meter.go
@@ -2,8 +2,6 @@ package chrono
import (
"time"
-
- . "github.com/navidrome/navidrome/utils/gg"
)
// Meter is a simple stopwatch
@@ -13,7 +11,7 @@ type Meter struct {
}
func (m *Meter) Start() {
- m.mark = P(time.Now())
+ m.mark = new(time.Now())
}
func (m *Meter) Stop() time.Duration {
diff --git a/utils/gg/gg.go b/utils/gg/gg.go
index 208fe2952..674cacf20 100644
--- a/utils/gg/gg.go
+++ b/utils/gg/gg.go
@@ -1,11 +1,6 @@
// Package gg implements simple "extensions" to Go language. Based on https://github.com/icza/gog
package gg
-// P returns a pointer to the input value
-func P[T any](v T) *T {
- return &v
-}
-
// V returns the value of the input pointer, or a zero value if the input pointer is nil.
func V[T any](p *T) T {
if p == nil {
diff --git a/utils/gg/gg_test.go b/utils/gg/gg_test.go
index 1d6dff484..a2dd8154f 100644
--- a/utils/gg/gg_test.go
+++ b/utils/gg/gg_test.go
@@ -16,22 +16,9 @@ func TestGG(t *testing.T) {
}
var _ = Describe("GG", func() {
- Describe("P", func() {
- It("returns a pointer to the input value", func() {
- v := 123
- Expect(gg.P(123)).To(Equal(&v))
- })
-
- It("returns nil if the input value is zero", func() {
- v := 0
- Expect(gg.P(0)).To(Equal(&v))
- })
- })
-
Describe("V", func() {
It("returns the value of the input pointer", func() {
- v := 123
- Expect(gg.V(&v)).To(Equal(123))
+ Expect(gg.V(new(123))).To(Equal(123))
})
It("returns a zero value if the input pointer is nil", func() {
diff --git a/utils/req/req.go b/utils/req/req.go
index 6b6135e1a..2757fc3f5 100644
--- a/utils/req/req.go
+++ b/utils/req/req.go
@@ -38,8 +38,7 @@ func (r *Values) String(param string) (string, error) {
func (r *Values) StringPtr(param string) *string {
var v *string
if _, exists := r.URL.Query()[param]; exists {
- s := r.URL.Query().Get(param)
- v = &s
+ v = new(r.URL.Query().Get(param))
}
return v
}
@@ -48,8 +47,7 @@ func (r *Values) BoolPtr(param string) *bool {
var v *bool
if _, exists := r.URL.Query()[param]; exists {
s := r.URL.Query().Get(param)
- b := strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/")
- v = &b
+ v = new(strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/"))
}
return v
}
diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go
index 65e5f0934..64cb89d53 100644
--- a/utils/slice/slice_test.go
+++ b/utils/slice/slice_test.go
@@ -134,7 +134,7 @@ var _ = Describe("Slice Utils", func() {
count := 0
file, _ := os.Open(path)
defer file.Close()
- for _ = range slice.LinesFrom(file) {
+ for range slice.LinesFrom(file) {
count++
}
Expect(count).To(Equal(expected))