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))