diff --git a/conf/configuration.go b/conf/configuration.go index 5f74d6db0..653e3d8e1 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -78,7 +78,7 @@ type configOptions struct { EnableFavourites bool EnableStarRating bool EnableUserEditing bool - EnableCoverArtUpload bool + EnableArtworkUpload bool EnableSharing bool ShareURL string DefaultShareExpiration time.Duration @@ -258,6 +258,13 @@ type searchOptions struct { FullString bool } +// logFatal prints a fatal error message to stderr and exits. +// Overridden in tests to allow testing fatal paths. +var logFatal = func(args ...any) { + _, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...) + os.Exit(1) +} + var ( Server = &configOptions{} hooks []func() @@ -267,14 +274,14 @@ func LoadFromFile(confFile string) { viper.SetConfigFile(confFile) err := viper.ReadInConfig() if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err) - os.Exit(1) + logFatal("Error reading config file:", err) } Load(true) } func Load(noConfigDump bool) { parseIniFileConfiguration() + remapEnvVarKeysFromConfig() // Map deprecated options to their new names for backwards compatibility mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources") @@ -284,14 +291,12 @@ func Load(noConfigDump bool) { err := viper.Unmarshal(&Server) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) - os.Exit(1) + logFatal("Error parsing config:", err) } err = os.MkdirAll(Server.DataFolder, os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err) - os.Exit(1) + logFatal("Error creating data path:", err) } if Server.CacheFolder == "" { @@ -299,14 +304,12 @@ func Load(noConfigDump bool) { } err = os.MkdirAll(Server.CacheFolder, os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err) - os.Exit(1) + logFatal("Error creating cache path:", err) } err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err) - os.Exit(1) + logFatal("Error creating artwork path:", err) } if Server.Plugins.Enabled { @@ -315,8 +318,7 @@ func Load(noConfigDump bool) { } err = os.MkdirAll(Server.Plugins.Folder, 0700) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err) - os.Exit(1) + logFatal("Error creating plugins path:", err) } } @@ -328,8 +330,7 @@ func Load(noConfigDump bool) { if Server.Backup.Path != "" { err = os.MkdirAll(Server.Backup.Path, os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err) - os.Exit(1) + logFatal("Error creating backup path:", err) } } @@ -337,8 +338,7 @@ func Load(noConfigDump bool) { if Server.LogFile != "" { out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error()) - os.Exit(1) + logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error())) } log.SetOutput(out) } else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" { @@ -370,8 +370,7 @@ func Load(noConfigDump bool) { if Server.BaseURL != "" { u, err := url.Parse(Server.BaseURL) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err) - os.Exit(1) + logFatal("Invalid BaseURL:", err) } Server.BasePath = u.Path u.Path = "" @@ -466,6 +465,35 @@ func logRemovedOptions(options ...string) { } } +// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly +// using environment variable names) and remaps them to canonical Viper keys with a warning. +func remapEnvVarKeysFromConfig() { + for _, key := range viper.AllKeys() { + if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) { + continue + } + stripped := strings.TrimPrefix(key, "nd_") + canonicalKey := strings.ReplaceAll(stripped, "_", ".") + displayNDKey := "ND_" + strings.ToUpper(stripped) + displayCanonical := toPascalCase(canonicalKey) + + if viper.InConfig(canonicalKey) { + logFatal(fmt.Sprintf( + "Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+ + "The 'ND_' prefix is only needed for environment variables, not config file keys.", + displayNDKey, displayCanonical, + )) + return + } + + viper.Set(canonicalKey, viper.Get(key)) + _, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+ + "The 'ND_' prefix is only needed for environment variables.\n", + displayNDKey, displayCanonical, + ) + } +} + // mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after // the config has been read by viper, but before unmarshalling it into the Config struct. func mapDeprecatedOption(legacyName, newName string) { @@ -483,18 +511,15 @@ func parseIniFileConfiguration() { var iniConfig map[string]any err := viper.Unmarshal(&iniConfig) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) - os.Exit(1) + logFatal("Error parsing config:", err) } cfg, ok := iniConfig["default"].(map[string]any) if !ok { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig) - os.Exit(1) + logFatal("Error parsing config: missing [default] section:", iniConfig) } err = viper.MergeConfigMap(cfg) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) - os.Exit(1) + logFatal("Error parsing config:", err) } } } @@ -617,6 +642,21 @@ func normalizeSearchBackend(value string) string { } } +// toPascalCase converts a dotted lowercase config key to PascalCase for display. +// Example: "scanner.schedule" → "Scanner.Schedule" +func toPascalCase(key string) string { + if key == "" { + return "" + } + parts := strings.Split(key, ".") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + return strings.Join(parts, ".") +} + // AddHook is used to register initialization code that should run as soon as the config is loaded func AddHook(hook func()) { hooks = append(hooks, hook) @@ -689,7 +729,7 @@ func setViperDefaults() { viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) - viper.SetDefault("enablecoverartupload", true) + viper.SetDefault("enableartworkupload", true) viper.SetDefault("enablesharing", false) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) @@ -820,8 +860,7 @@ func InitConfig(cfgFile string, loadEnvVars bool) { err := viper.ReadInConfig() if viper.ConfigFileUsed() != "" && err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err) - os.Exit(1) + logFatal("Navidrome could not open config file:", err) } } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 73fec4196..eb2176e83 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -2,6 +2,7 @@ package conf_test import ( "fmt" + "os" "path/filepath" "testing" @@ -24,6 +25,11 @@ var _ = Describe("Configuration", func() { viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("loglevel", "error") conf.ResetConf() + + // Panic instead of exiting on fatal errors to allow testing error conditions + DeferCleanup(conf.SetLogFatal(func(args ...any) { + panic(fmt.Sprint(args...)) + })) }) Describe("ParseLanguages", func() { @@ -108,6 +114,111 @@ var _ = Describe("Configuration", func() { Entry("falls back to 'fts' for empty string", "", "fts"), ) + DescribeTable("ToPascalCase", + func(input, expected string) { + Expect(conf.ToPascalCase(input)).To(Equal(expected)) + }, + Entry("simple key", "address", "Address"), + Entry("dotted key", "scanner.schedule", "Scanner.Schedule"), + Entry("already capitalized", "Address", "Address"), + Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"), + Entry("empty string", "", ""), + ) + + Describe("remapEnvVarKeysFromConfig", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + It("remaps ND_-prefixed keys to canonical keys", func() { + filename := filepath.Join("testdata", "cfg_nd_keys.toml") + conf.InitConfig(filename, false) + conf.Load(true) + + Expect(conf.Server.Address).To(Equal("127.0.0.1")) + Expect(conf.Server.Port).To(Equal(4531)) + Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h")) + }) + + It("exits with fatal error when both ND_ and canonical key exist", func() { + filename := filepath.Join("testdata", "cfg_nd_conflict.toml") + conf.InitConfig(filename, false) + + Expect(func() { conf.Load(true) }).To(PanicWith(And( + ContainSubstring("ND_ADDRESS"), + ContainSubstring("Address"), + ContainSubstring("only needed for environment variables"), + ))) + }) + + It("does nothing when no ND_ keys are present", func() { + filename := filepath.Join("testdata", "cfg.toml") + conf.InitConfig(filename, false) + conf.Load(true) + + // Verify normal config loading still works + Expect(conf.Server.MusicFolder).To(Equal("/toml/music")) + }) + }) + + Describe("logFatal", func() { + var invalidPath string + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("loglevel", "error") + conf.ResetConf() + + // Create a file so that any path under it is invalid on all OSes + f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker") + Expect(err).ToNot(HaveOccurred()) + f.Close() + invalidPath = filepath.Join(f.Name(), "subdir") + }) + + It("is called when LoadFromFile gets an invalid config file", func() { + Expect(func() { + conf.LoadFromFile(filepath.Join(invalidPath, "file.toml")) + }).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"))) + }) + + It("is called when BaseURL is invalid", func() { + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("baseurl", "://invalid") + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Invalid BaseURL"))) + }) + + }) + DescribeTable("should load configuration from", func(format string) { filename := filepath.Join("testdata", "cfg."+format) diff --git a/conf/export_test.go b/conf/export_test.go index d1d1bb3a9..051f9bb65 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -11,3 +11,11 @@ var ParseLanguages = parseLanguages var ValidateURL = validateURL var NormalizeSearchBackend = normalizeSearchBackend + +var ToPascalCase = toPascalCase + +func SetLogFatal(f func(...any)) func() { + old := logFatal + logFatal = f + return func() { logFatal = old } +} diff --git a/conf/testdata/cfg_nd_conflict.toml b/conf/testdata/cfg_nd_conflict.toml new file mode 100644 index 000000000..2e8b94bc3 --- /dev/null +++ b/conf/testdata/cfg_nd_conflict.toml @@ -0,0 +1,2 @@ +ND_ADDRESS = "127.0.0.1" +Address = "0.0.0.0" diff --git a/conf/testdata/cfg_nd_keys.toml b/conf/testdata/cfg_nd_keys.toml new file mode 100644 index 000000000..de441ce66 --- /dev/null +++ b/conf/testdata/cfg_nd_keys.toml @@ -0,0 +1,3 @@ +ND_ADDRESS = "127.0.0.1" +ND_PORT = 4531 +ND_SCANNER_SCHEDULE = "@every 1h" diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 0628461e0..d830593fc 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -130,10 +130,25 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc if err != nil { return nil, "", err } - return r, path, nil + // Validate that the stream actually contains image data by reading the first byte. + // ffmpeg.ExtractImage returns a pipe reader that may fail asynchronously if the + // file has no video/image stream (e.g., an MP3 without embedded art). + buf := make([]byte, 1) + n, err := r.Read(buf) + if n == 0 || err != nil { + r.Close() + return nil, "", fmt.Errorf("ffmpeg produced no image data for %s: %w", path, err) + } + return readCloser{Reader: io.MultiReader(bytes.NewReader(buf[:n]), r), Closer: r}, path, nil } } +// readCloser combines a Reader and a Closer into an io.ReadCloser. +type readCloser struct { + io.Reader + io.Closer +} + func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc { return func() (io.ReadCloser, string, error) { r, _, err := a.Get(ctx, id, 0, false) diff --git a/core/external/provider.go b/core/external/provider.go index a30afed15..40ca34069 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -374,8 +374,6 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) return nil, err } - // Use already-stored image URL if available, avoiding expensive external API calls. - // If the info is expired, the background refresh (via UpdateArtistInfo/artistQueue) will update it. imageUrl := artist.ArtistImageUrl() if imageUrl == "" { // No cached URL — must fetch from external source synchronously @@ -385,6 +383,14 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) return nil, ctx.Err() } imageUrl = artist.ArtistImageUrl() + } else { + // If cached info is expired, enqueue a background refresh so that config changes + // (e.g. disabling an agent) take effect without waiting for a full artist info refresh. + updatedAt := V(artist.ExternalInfoUpdatedAt) + if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive { + log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt) + e.artistQueue.enqueue(&artist) + } } if imageUrl == "" { diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go index 11290bb66..529289ed3 100644 --- a/core/external/provider_artistimage_test.go +++ b/core/external/provider_artistimage_test.go @@ -1,14 +1,17 @@ package external_test import ( + "bytes" "context" "errors" "net/url" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -266,6 +269,68 @@ var _ = Describe("Provider - ArtistImage", func() { mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") }) + 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, + } + mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() + expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") + + // Capture log output + var logBuf bytes.Buffer + log.SetOutput(&logBuf) + defer log.SetOutput(GinkgoWriter) + log.SetLevel(log.LevelDebug) + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-cached") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything) + + // Assert: background refresh was NOT enqueued + Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh")) + + }) + + 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, + } + mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() + expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") + + // Capture log output + var logBuf bytes.Buffer + log.SetOutput(&logBuf) + defer log.SetOutput(GinkgoWriter) + log.SetLevel(log.LevelDebug) + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-expired") + + // Assert: returns stale URL immediately, no agent call + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything) + + // Assert: background refresh was enqueued + Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh")) + }) + Context("Unicode handling in artist names", func() { var artistWithEnDash *model.Artist var expectedURL *url.URL diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 04663828f..01b284172 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -584,9 +584,12 @@ var _ = Describe("ffmpeg", func() { // Cancel the context cancel() - // Next read should fail due to cancelled context - _, err = stream.Read(buf) - Expect(err).To(HaveOccurred()) + // Subsequent reads should eventually fail due to cancelled context. + // There may be buffered data in the pipe, so we drain until an error occurs. + Eventually(func() error { + _, err = stream.Read(buf) + return err + }).WithTimeout(5 * time.Second).WithPolling(10 * time.Millisecond).Should(HaveOccurred()) }) It("should handle immediate context cancellation", func() { diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 5f3f491ea..b87f1df5e 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -193,6 +193,8 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != "" data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache + data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload + data.Config.CoverArtQuality = conf.Server.CoverArtQuality data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying data.Config.EnableDownloads = conf.Server.EnableDownloads diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 27186e020..b316c866d 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -63,6 +63,8 @@ type Data struct { EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` EnableJukebox bool `json:"enableJukebox,omitempty"` EnablePrometheus bool `json:"enablePrometheus,omitempty"` + EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` + CoverArtQuality int `json:"coverArtQuality,omitempty"` EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` SessionTimeout uint64 `json:"sessionTimeout,omitempty"` diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index 9eb01f0cf..1d4116b5d 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -178,7 +178,9 @@ func buildFTS5Query(userInput string) string { tokens[i] = t + "*" } - result = strings.Join(tokens, " ") + // Use explicit AND between tokens — FTS5's implicit AND (space-separated) + // doesn't work correctly with parenthesized OR groups from processPunctuatedWords. + result = strings.Join(tokens, " AND ") for i, phrase := range phrases { placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i) diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index 337d54201..d0e26c8e3 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -17,32 +17,33 @@ var _ = DescribeTable("buildFTS5Query", Entry("returns empty string for empty input", "", ""), Entry("returns empty string for whitespace-only input", " ", ""), Entry("appends * to a single word for prefix matching", "beatles", "beatles*"), - Entry("appends * to each word for prefix matching", "abbey road", "abbey* road*"), + Entry("appends * to each word for prefix matching", "abbey road", "abbey* AND road*"), Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`), Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"), - Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* or* not* near*"), - Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* col* val*"), - Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" abbey*`), - Entry("handles prefix with multiple words", "beat* abbey", "beat* abbey*"), - Entry("collapses multiple spaces", "abbey road", "abbey* road*"), + Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* AND or* AND not* AND near*"), + Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* AND col* AND val*"), + Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND abbey*`), + Entry("handles prefix with multiple words", "beat* abbey", "beat* AND abbey*"), + Entry("collapses multiple spaces", "abbey road", "abbey* AND road*"), Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"), - Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* oliv*"), + Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* AND oliv*"), Entry("strips standalone *", "*", ""), - Entry("strips apostrophe from input", "Guns N' Roses", "Guns* N* Roses*"), + Entry("strips apostrophe from input", "Guns N' Roses", "Guns* AND N* AND Roses*"), Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`), Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`), Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`), Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`), Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`), - Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* of* ("a ha" OR aha*)`), - Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* roll* vol* 2*"), - Entry("preserves unicode characters with diacritics", "Björk début", "Björk* début*"), + Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`), + Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`), + Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"), + Entry("preserves unicode characters with diacritics", "Björk début", "Björk* AND début*"), Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`), Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`), - Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* of* "R E M"`), + Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`), Entry("collapses two-letter abbreviation", "U.K.", `"U K"`), - Entry("does not collapse single letter surrounded by words", "I am fine", "I* am* fine*"), - Entry("does not collapse single standalone letter", "A test", "A* test*"), + Entry("does not collapse single letter surrounded by words", "I am fine", "I* AND am* AND fine*"), + Entry("does not collapse single standalone letter", "A test", "A* AND test*"), Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`), Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`), Entry("returns empty string for punctuation-only input", "!!!!!!!", ""), diff --git a/resources/i18n/da.json b/resources/i18n/da.json index a47b30bbc..edb1df183 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -38,7 +38,7 @@ "missing": "Manglende", "libraryName": "Bibliotek", "composer": "Komponist", - "disc": "" + "disc": "Disk %{discNumber}" }, "actions": { "addToQueue": "Afspil senere", @@ -355,7 +355,7 @@ "selectedUsers": "Valgte brugere", "allLibraries": "Tillad alle biblioteker", "selectedLibraries": "Valgte biblioteker", - "allowWriteAccess": "" + "allowWriteAccess": "Tillad skriveadgang" }, "sections": { "status": "Status", @@ -401,7 +401,7 @@ "requiredHosts": "Påkrævede hosts", "configValidationError": "Konfigurationsvalidering mislykkedes:", "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Når aktiveret, kan denne plugin rette filer i biblioteksmapper. På forhånd har plugins kun læseadgang." }, "placeholders": { "configKey": "nøgle", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.", "noSimilarSongsFound": "Ingen lignende sange fundet", "noTopSongsFound": "Ingen topsange fundet", - "startingInstantMix": "Indlæser Instant Mix..." + "startingInstantMix": "Indlæser Instant Mix...", + "uploadCover": "Upload omslag", + "removeCover": "Fjern omslag", + "coverUploaded": "Omslagsbillede opdateret", + "coverRemoved": "Omslagsbillede fjernet", + "coverUploadError": "Fejl ved upload af omslagsbillede", + "coverRemoveError": "Fejl ved fjernelse af omslagsbillede" }, "menu": { "library": "Bibliotek", @@ -712,4 +718,4 @@ "empty": "Intet afspilles nu", "minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden" } -} +} \ No newline at end of file diff --git a/resources/i18n/de.json b/resources/i18n/de.json index ab1760ed5..c540dee05 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -18,7 +18,7 @@ "size": "Dateigröße", "updatedAt": "Hochgeladen am", "bitRate": "Bitrate", - "discSubtitle": "CD Untertitel", + "discSubtitle": "Disc Untertitel", "starred": "Favorit", "comment": "Kommentar", "rating": "Bewertung", @@ -38,7 +38,7 @@ "missing": "Fehlend", "libraryName": "Bibliothek", "composer": "Komponist", - "disc": "" + "disc": "Disc %{discNumber}" }, "actions": { "addToQueue": "Später abspielen", @@ -718,4 +718,4 @@ "empty": "Keine Wiedergabe", "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten" } -} +} \ No newline at end of file diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 019d05978..3876c2e33 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -38,7 +38,7 @@ "missing": "Απών", "libraryName": "Βιβλιοθήκη", "composer": "Συνθέτης", - "disc": "" + "disc": "Δίσκος %{discNumber}" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -355,7 +355,7 @@ "selectedUsers": "Επιλογή χρηστών", "allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες", "selectedLibraries": "Επιλεγμένες βιβλιοθήκες", - "allowWriteAccess": "" + "allowWriteAccess": "Επιτρέψτε την πρόσβαση εγγραφής" }, "sections": { "status": "Κατάσταση", @@ -401,7 +401,7 @@ "requiredHosts": "Απαιτούμενοι hosts", "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση." }, "placeholders": { "configKey": "κλειδί", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.", "noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια", "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια", - "startingInstantMix": "Φόρτωση Άμεσης Μίξης..." + "startingInstantMix": "Φόρτωση Άμεσης Μίξης...", + "uploadCover": "Μεταφόρτωση εξωφύλλου", + "removeCover": "Αφαίρεση καλύμματος", + "coverUploaded": "Το εξώφυλλο ενημερώθηκε", + "coverRemoved": "Το εξώφυλλο αφαιρέθηκε", + "coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου", + "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου" }, "menu": { "library": "Βιβλιοθήκη", @@ -712,4 +718,4 @@ "empty": "Δεν παίζει τίποτα", "minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν" } -} +} \ No newline at end of file diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 29d1a367f..a018eda3d 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -38,7 +38,7 @@ "missing": "Faltante", "libraryName": "Biblioteca", "composer": "Compositor", - "disc": "" + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Reproducir después", @@ -355,7 +355,7 @@ "selectedUsers": "Usuarios seleccionados", "allLibraries": "Permitir todas las bibliotecas", "selectedLibraries": "Bibliotecas seleccionadas", - "allowWriteAccess": "" + "allowWriteAccess": "Permitir acceso de escritura" }, "sections": { "status": "Estado", @@ -401,7 +401,7 @@ "requiredHosts": "Hosts requeridos", "configValidationError": "La validación de la configuración falló:", "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Cuando está activado, el plugin puede modificar archivos en los directorios de la biblioteca. Por defecto, los plugins tienen acceso de solo lectura." }, "placeholders": { "configKey": "clave", @@ -591,7 +591,13 @@ "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", "noSimilarSongsFound": "No se encontraron canciones similares", "noTopSongsFound": "No se encontraron canciones destacadas", - "startingInstantMix": "Cargando la mezcla instantánea..." + "startingInstantMix": "Cargando la mezcla instantánea...", + "uploadCover": "Subir portada", + "removeCover": "Eliminar portada", + "coverUploaded": "Portada actualizada", + "coverRemoved": "Portada eliminada", + "coverUploadError": "Error al subir la portada", + "coverRemoveError": "Error al eliminar la portada" }, "menu": { "library": "Biblioteca", @@ -712,4 +718,4 @@ "empty": "Nada en reproducción", "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos" } -} +} \ No newline at end of file diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 59f353350..bbad47bd6 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -38,7 +38,7 @@ "missing": "Puuttuva", "libraryName": "Kirjasto", "composer": "Säveltäjä", - "disc": "" + "disc": "Levy %{discNumber}" }, "actions": { "addToQueue": "Lisää jonoon", @@ -718,4 +718,4 @@ "empty": "Ei soita mitään", "minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten" } -} +} \ No newline at end of file diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 891fde03a..dae9f47e7 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -38,7 +38,7 @@ "missing": "Manquant", "libraryName": "Bibliothèque", "composer": "Compositeur·e", - "disc": "" + "disc": "Disque %{discNumber}" }, "actions": { "addToQueue": "Ajouter à la file", @@ -355,7 +355,7 @@ "selectedUsers": "Utilisateur·rices sélectionné.e.s", "allLibraries": "Autoriser toutes les bibliothèques", "selectedLibraries": "Bibliothèques sélectionnées", - "allowWriteAccess": "" + "allowWriteAccess": "Autoriser l'accès en écriture" }, "sections": { "status": "Statut", @@ -401,7 +401,7 @@ "requiredHosts": "Hôtes requis", "configValidationError": "Erreur lors de la validation de la configuration", "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Lorsque cette option est activée, le plugin peut modifier les fichiers dans les répertoires de la bibliothèque. Par défaut, les plugins ont un accès en lecture seule." }, "placeholders": { "configKey": "clef", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.", "noSimilarSongsFound": "Aucun titre similaire n'a été trouvé", "noTopSongsFound": "Aucun meilleur titre n'a été trouvé", - "startingInstantMix": "Chargement du mix instantanné..." + "startingInstantMix": "Chargement du mix instantané...", + "uploadCover": "Téléverser la pochette", + "removeCover": "Supprimer la pochette", + "coverUploaded": "Pochette mise à jour", + "coverRemoved": "Pochette supprimée", + "coverUploadError": "Erreur lors du téléversement de la pochette", + "coverRemoveError": "Erreur lors de la suppression de la pochette" }, "menu": { "library": "Bibliothèque", @@ -712,4 +718,4 @@ "empty": "Aucun titre en cours de lecture", "minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes" } -} +} \ No newline at end of file diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index aba22b714..d62ca2ab2 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -38,7 +38,7 @@ "missing": "Falta", "libraryName": "Biblioteca", "composer": "Composición", - "disc": "" + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Ao final da cola", @@ -355,7 +355,7 @@ "selectedUsers": "Usuarias seleccionadas", "allLibraries": "Permitir todas as bibliotecas", "selectedLibraries": "Selecciona bibliotecas", - "allowWriteAccess": "" + "allowWriteAccess": "Conceder acceso de escritura" }, "sections": { "status": "Estado", @@ -401,7 +401,7 @@ "requiredHosts": "Servidores requeridos", "configValidationError": "Fallou a comprobación da configuración:", "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura." }, "placeholders": { "configKey": "clave", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.", "noSimilarSongsFound": "Sen cancións parecidas", "noTopSongsFound": "Sen cancións destacadas", - "startingInstantMix": "Cargando Mestura Súbita…" + "startingInstantMix": "Cargando Mestura Súbita…", + "uploadCover": "Subir capa", + "removeCover": "Retirar capa", + "coverUploaded": "Subiuse a capa", + "coverRemoved": "Retirouse a capa", + "coverUploadError": "Erro ao subir a capa", + "coverRemoveError": "Erro ao retirar a capa" }, "menu": { "library": "Biblioteca", @@ -712,4 +718,4 @@ "empty": "Sen reprodución", "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos" } -} +} \ No newline at end of file diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json index 115b2d1a4..ce3d5ae87 100644 --- a/resources/i18n/hu.json +++ b/resources/i18n/hu.json @@ -22,6 +22,7 @@ "bitRate": "Bitráta", "bitDepth": "Bitmélység", "sampleRate": "Mintavételezési frekvencia", + "disc": "Lemez %{discNumber}", "discSubtitle": "Lemezfelirat", "starred": "Kedvenc", "comment": "Megjegyzés", @@ -350,7 +351,8 @@ "allUsers": "Összes felhasználó engedélyezése", "selectedUsers": "Kiválasztott felhasználók engedélyezése", "allLibraries": "Összes könyvtár engedélyezése", - "selectedLibraries": "Kiválasztott könyvtárak engedélyezése" + "selectedLibraries": "Kiválasztott könyvtárak engedélyezése", +"allowWriteAccess": "Írási hozzáférés engedélyezése" }, "sections": { "status": "Státusz", @@ -395,6 +397,7 @@ "allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.", "noLibraries": "Nincs kiválasztott könyvtár", "librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.", +"allowWriteAccessHelp": "Amikor ez engedélyezve van, a kiegészítő módosíthatja a könyvtár mappáit. Alapbeállításon a kiegészítőknek csak olvasási joguk van.", "requiredHosts": "Szükséges hostok" }, "placeholders": { @@ -549,6 +552,12 @@ } }, "message": { + "uploadCover": "Borítókép feltöltése", + "removeCover": "Borítókép törlése", + "coverUploaded": "Borítókép feltöltve", + "coverRemoved": "Borítókép eltávolítva", + "coverUploadError": "Borítókép feltöltése sikertelen", + "coverRemoveError": "Borítókép törlése sikertelen", "note": "MEGJEGYZÉS", "transcodingDisabled": "Az átkódolási konfiguráció módosítása a webes felületen keresztül biztonsági okokból nem lehetséges. Ha módosítani szeretnéd az átkódolási beállításokat, indítsd újra a kiszolgálót a %{config} konfigurációs opcióval.", "transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.", diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index bc2a5f85e..2e4f517a9 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -37,7 +37,8 @@ "sampleRate": "Taxa de amostragem", "missing": "Ausente", "libraryName": "Biblioteca", - "composer": "Compositor" + "composer": "Compositor", + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Adicionar à fila", @@ -397,10 +398,10 @@ "allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.", "noLibraries": "Nenhuma biblioteca selecionada", "librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.", - "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.", "requiredHosts": "Hosts necessários", "configValidationError": "Falha na validação da configuração:", - "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido." + "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.", + "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura." }, "placeholders": { "configKey": "chave", @@ -554,12 +555,6 @@ } }, "message": { - "uploadCover": "Enviar Capa", - "removeCover": "Remover Capa", - "coverUploaded": "Capa atualizada", - "coverRemoved": "Capa removida", - "coverUploadError": "Erro ao enviar capa", - "coverRemoveError": "Erro ao remover capa", "note": "ATENÇÃO", "transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}", "transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão", @@ -596,7 +591,13 @@ "remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.", "noSimilarSongsFound": "Nenhuma música semelhante encontrada", "noTopSongsFound": "Nenhuma música mais tocada encontrada", - "startingInstantMix": "Carregando Mix Instantâneo..." + "startingInstantMix": "Carregando Mix Instantâneo...", + "uploadCover": "Enviar Capa", + "removeCover": "Remover Capa", + "coverUploaded": "Capa atualizada", + "coverRemoved": "Capa removida", + "coverUploadError": "Erro ao enviar capa", + "coverRemoveError": "Erro ao remover capa" }, "menu": { "library": "Biblioteca", diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 23bd5fbc2..228cc2cf3 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -38,7 +38,7 @@ "missing": "Saknade", "libraryName": "Bibliotek", "composer": "Kompositör", - "disc": "" + "disc": "Disc %{discNumber}" }, "actions": { "addToQueue": "Lägg till i kön", @@ -355,7 +355,7 @@ "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", "selectedLibraries": "Valda bibliotek", - "allowWriteAccess": "" + "allowWriteAccess": "Tillåt skrivrättigheter" }, "sections": { "status": "Status", @@ -401,7 +401,7 @@ "requiredHosts": "Krävda värdar", "configValidationError": "Validering av konfigurationen misslyckades:", "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter." }, "placeholders": { "configKey": "nyckel", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", "noSimilarSongsFound": "Hittade inga liknande låtar", "noTopSongsFound": "Hittade inga topplåtar", - "startingInstantMix": "Laddar direktmix..." + "startingInstantMix": "Laddar direktmix...", + "uploadCover": "Ladda upp omslagsbild", + "removeCover": "Ta bort omslagsbild", + "coverUploaded": "Omslagsbild uppdaterad", + "coverRemoved": "Omslagsbild borttagen", + "coverUploadError": "Fel vid uppladdning av omslagsbild", + "coverRemoveError": "Fel vid borttagning av omslagsbild" }, "menu": { "library": "Bibliotek", @@ -712,4 +718,4 @@ "empty": "Inget spelas", "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } -} +} \ No newline at end of file diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json index 2c74c890a..c5644fde7 100644 --- a/resources/i18n/uk.json +++ b/resources/i18n/uk.json @@ -36,7 +36,9 @@ "bitDepth": "Глибина розрядності", "sampleRate": "Частота дискретизації", "missing": "Поле відсутнє", - "libraryName": "Бібліотека" + "libraryName": "Бібліотека", + "composer": "Композитор", + "disc": "Диск %{discNumber}" }, "actions": { "addToQueue": "Прослухати пізніше", @@ -46,7 +48,8 @@ "download": "Завантажити", "playNext": "Наступна", "info": "Отримати інформацію", - "showInPlaylist": "Показати у плейлісті" + "showInPlaylist": "Показати у плейлісті", + "instantMix": "Мікс" } }, "album": { @@ -328,6 +331,82 @@ "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": "Налаштуйте плагін використовуючи пару ключ-значення. Залиште порожнім, якщо плагін не вимагає конфігурації.", + "clickPermissions": "Натисніть дозволи для детальної інформації", + "noConfig": "Конфігурація не налаштована", + "allUsersHelp": "При увімкненні плагін матиме доступ до всіх користувачів, включно ті, які будуть створені в майбутньому.", + "noUsers": "Немає вибраних користувачів", + "permissionReason": "Причина", + "usersRequired": "Цей плагін вимагає доступу до інформації про користувача. Виберіть, до яких користувачів плагін може отримати доступ, або ввімкніть «Дозволити всім користувачам».", + "allLibrariesHelp": "Коли увімкнуто, плагін матиме доступ до всіх бібліотек, включаючи ті, які будуть створені в майбутньому.", + "noLibraries": "Немає виділених бібліотек", + "librariesRequired": "Цей плагін вимагає доступу до інформації бібліотеки. Виберіть, до яких бібліотек плагін може отримати доступ, або ввімкніть «Дозволити всі бібліотеки».", + "requiredHosts": "Обов'язкові хости", + "configValidationError": "Перевірка конфігурації зазнала невдачі:", + "schemaRenderError": "Неможливо відобразити форму конфігурації. Схема плагіна може бути недійсною.", + "allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання." + }, + "placeholders": { + "configKey": "ключ", + "configValue": "значення" + } } }, "ra": { @@ -511,7 +590,14 @@ "remove_all_missing_title": "Видалити всі відсутні файли", "remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами.", "noSimilarSongsFound": "Не знайдено схожих треків", - "noTopSongsFound": "Не знайдено ТОП-треків" + "noTopSongsFound": "Не знайдено ТОП-треків", + "startingInstantMix": "Завантаження міксу...", + "uploadCover": "Завантажити обкладинку", + "removeCover": "Видалити обкладинку", + "coverUploaded": "Обкладинку оновлено", + "coverRemoved": "Обкладинка видалена", + "coverUploadError": "Помилка завантаження обкладинки", + "coverRemoveError": "Помилка видалення обкладинки" }, "menu": { "library": "Бібліотека", @@ -597,7 +683,8 @@ "exportSuccess": "Конфігурацію експортовано в буфер обміну у форматі TOML", "exportFailed": "Не вдалося скопіювати конфігурацію", "devFlagsHeader": "Прапорці розробки (можуть бути змінені/видалені)", - "devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях." + "devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях.", + "downloadToml": "Завантажити конфігурацію (TOML)" } }, "activity": { diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index dabf61bdb..93951a311 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -38,7 +38,7 @@ "missing": "遺失", "libraryName": "媒體庫", "composer": "作曲者", - "disc": "" + "disc": "光碟 %{discNumber}" }, "actions": { "addToQueue": "加入至播放佇列", @@ -718,4 +718,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} +} \ No newline at end of file diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index c29f14bdc..1f55e3851 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -24,8 +24,8 @@ const maxImageSize = 10 << 20 // 10MB func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { user, _ := request.UserFrom(r.Context()) - if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { - http.Error(w, "cover art upload is disabled", http.StatusForbidden) + if !conf.Server.EnableArtworkUpload && !user.IsAdmin { + http.Error(w, "artwork upload is disabled", http.StatusForbidden) return false } return true diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index dfe6b9296..e1c933709 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -28,8 +28,8 @@ var _ = Describe("Playlist Image Endpoints", func() { }) DescribeTable("uploadPlaylistImage guard", - func(enableCoverArtUpload, isAdmin bool, expectedStatus int) { - conf.Server.EnableCoverArtUpload = enableCoverArtUpload + func(enableArtworkUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableArtworkUpload = enableArtworkUpload handler := uploadPlaylistImage(&mockPlaylistsService{}) req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil) @@ -47,8 +47,8 @@ var _ = Describe("Playlist Image Endpoints", func() { ) DescribeTable("deletePlaylistImage guard", - func(enableCoverArtUpload, isAdmin bool, expectedStatus int) { - conf.Server.EnableCoverArtUpload = enableCoverArtUpload + func(enableArtworkUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableArtworkUpload = enableArtworkUpload handler := deletePlaylistImage(&mockPlaylistsService{}) req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil) diff --git a/server/serve_index.go b/server/serve_index.go index 6b0c890a6..0d1a2f330 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -61,7 +61,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, "enableUserEditing": conf.Server.EnableUserEditing, - "enableCoverArtUpload": conf.Server.EnableCoverArtUpload, + "enableArtworkUpload": conf.Server.EnableArtworkUpload, "enableSharing": conf.Server.EnableSharing, "shareURL": conf.Server.ShareURL, "defaultDownloadableShare": conf.Server.DefaultDownloadableShare, diff --git a/server/subsonic/users.go b/server/subsonic/users.go index 4f6dccaac..8b7406b60 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -22,7 +22,7 @@ func buildUserResponse(user model.User) responses.User { ScrobblingEnabled: true, DownloadRole: conf.Server.EnableDownloads, ShareRole: conf.Server.EnableSharing, - CoverArtRole: conf.Server.EnableCoverArtUpload || user.IsAdmin, + CoverArtRole: conf.Server.EnableArtworkUpload || user.IsAdmin, Folder: slice.Map(user.Libraries, func(lib model.Library) int32 { return int32(lib.ID) }), } diff --git a/server/subsonic/users_test.go b/server/subsonic/users_test.go index 1fd5dce71..2d08b3377 100644 --- a/server/subsonic/users_test.go +++ b/server/subsonic/users_test.go @@ -105,8 +105,8 @@ var _ = Describe("Users", func() { ) DescribeTable("CoverArt role permissions", - func(enableCoverArtUpload, isAdmin, expectedCoverArtRole bool) { - conf.Server.EnableCoverArtUpload = enableCoverArtUpload + func(enableArtworkUpload, isAdmin, expectedCoverArtRole bool) { + conf.Server.EnableArtworkUpload = enableArtworkUpload testUser.IsAdmin = isAdmin response := buildUserResponse(testUser) diff --git a/ui/src/common/ImageUploadOverlay.jsx b/ui/src/common/ImageUploadOverlay.jsx index e0d0d0a9a..a370e40fe 100644 --- a/ui/src/common/ImageUploadOverlay.jsx +++ b/ui/src/common/ImageUploadOverlay.jsx @@ -49,7 +49,7 @@ export const ImageUploadOverlay = ({ const fileInputRef = useRef(null) const canEdit = - config.enableCoverArtUpload || localStorage.getItem('role') === 'admin' + config.enableArtworkUpload || localStorage.getItem('role') === 'admin' const handleUploadClick = useCallback((e) => { e.stopPropagation() diff --git a/ui/src/config.js b/ui/src/config.js index 0672a58f4..bd11d7df6 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -22,7 +22,7 @@ const defaultConfig = { defaultUIVolume: 100, uiSearchDebounceMs: 200, enableUserEditing: true, - enableCoverArtUpload: true, + enableArtworkUpload: true, enableSharing: true, shareURL: '', defaultDownloadableShare: true,