Merge branch 'master' into radio-browser-search/5239

This commit is contained in:
Markus Busche 2026-03-31 23:45:05 +02:00 committed by GitHub
commit a2374f8d57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 505 additions and 113 deletions

View File

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

View File

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

View File

@ -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 }
}

2
conf/testdata/cfg_nd_conflict.toml vendored Normal file
View File

@ -0,0 +1,2 @@
ND_ADDRESS = "127.0.0.1"
Address = "0.0.0.0"

3
conf/testdata/cfg_nd_keys.toml vendored Normal file
View File

@ -0,0 +1,3 @@
ND_ADDRESS = "127.0.0.1"
ND_PORT = 4531
ND_SCANNER_SCHEDULE = "@every 1h"

View File

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

View File

@ -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 == "" {

View File

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

View File

@ -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() {

View File

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

View File

@ -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"`

View File

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

View File

@ -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", "!!!!!!!", ""),

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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} λεπτά πριν"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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.",

View File

@ -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",

View File

@ -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"
}
}
}

View File

@ -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": {

View File

@ -38,7 +38,7 @@
"missing": "遺失",
"libraryName": "媒體庫",
"composer": "作曲者",
"disc": ""
"disc": "光碟 %{discNumber}"
},
"actions": {
"addToQueue": "加入至播放佇列",
@ -718,4 +718,4 @@
"empty": "無播放內容",
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
}
}
}

View File

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

View File

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

View File

@ -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,

View File

@ -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) }),
}

View File

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

View File

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

View File

@ -22,7 +22,7 @@ const defaultConfig = {
defaultUIVolume: 100,
uiSearchDebounceMs: 200,
enableUserEditing: true,
enableCoverArtUpload: true,
enableArtworkUpload: true,
enableSharing: true,
shareURL: '',
defaultDownloadableShare: true,