mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
24 Commits
5376498669
...
891dd73dcf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
891dd73dcf | ||
|
|
2018979bc3 | ||
|
|
e7c7cba873 | ||
|
|
93631cdee9 | ||
|
|
c87db92cee | ||
|
|
80c1e60259 | ||
|
|
23f3556371 | ||
|
|
c60637de24 | ||
|
|
220019a9f1 | ||
|
|
6109bf5192 | ||
|
|
4030bfe06f | ||
|
|
c5bb920b88 | ||
|
|
0f6a076dca | ||
|
|
420d2c8e5a | ||
|
|
9fe9cf3ff6 | ||
|
|
a293d12034 | ||
|
|
dc99994bdd | ||
|
|
049fc78177 | ||
|
|
2b041c02ad | ||
|
|
2588558946 | ||
|
|
f33ca75378 | ||
|
|
79e1af7cd6 | ||
|
|
ccee33f474 | ||
|
|
33e20d355e |
@ -55,6 +55,7 @@ linters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
@ -62,3 +63,4 @@ formatters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
|
||||
4
Makefile
4
Makefile
@ -1,6 +1,8 @@
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5
|
||||
|
||||
comma:=,
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
|
||||
|
||||
# Set global environment variables, required for most targets
|
||||
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||
|
||||
@ -70,6 +70,7 @@ type configOptions struct {
|
||||
MPVCmdTemplate string
|
||||
CoverArtPriority string
|
||||
CoverArtQuality int
|
||||
EnableWebPEncoding bool
|
||||
ArtistArtPriority string
|
||||
ArtistImageFolder string
|
||||
DiscArtPriority string
|
||||
@ -78,7 +79,7 @@ type configOptions struct {
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableCoverArtUpload bool
|
||||
EnableArtworkUpload bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
@ -87,6 +88,7 @@ type configOptions struct {
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
UISearchDebounceMs int
|
||||
UICoverArtSize int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
EnableNowPlaying bool
|
||||
@ -141,7 +143,6 @@ type configOptions struct {
|
||||
DevOptimizeDB bool
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
DevJpegCoverArt bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
@ -258,6 +259,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 +275,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 +292,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 +305,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 +319,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 +331,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 +339,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 +371,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 = ""
|
||||
@ -425,6 +425,13 @@ func Load(noConfigDump bool) {
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
|
||||
// Validate other options
|
||||
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
|
||||
newValue := max(200, min(1200, Server.UICoverArtSize))
|
||||
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
|
||||
Server.UICoverArtSize = newValue
|
||||
}
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
hook()
|
||||
@ -466,6 +473,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 +519,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 +650,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)
|
||||
@ -672,10 +720,13 @@ func setViperDefaults() {
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
viper.SetDefault("mpvpath", "")
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverartquality", 75)
|
||||
viper.SetDefault("enablewebpencoding", false)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
viper.SetDefault("artistimagefolder", "")
|
||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
@ -686,10 +737,11 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
|
||||
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)
|
||||
@ -754,6 +806,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("plugins.enabled", true)
|
||||
viper.SetDefault("plugins.cachesize", "200MB")
|
||||
viper.SetDefault("plugins.autoreload", false)
|
||||
viper.SetDefault("plugins.loglevel", "")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
@ -767,7 +820,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU()))
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
@ -783,7 +836,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devoptimizedb", true)
|
||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||
viper.SetDefault("devenablemediafileprobe", true)
|
||||
viper.SetDefault("devjpegcoverart", false)
|
||||
}
|
||||
|
||||
func init() {
|
||||
@ -820,8 +872,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
2
conf/testdata/cfg_nd_conflict.toml
vendored
Normal 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
3
conf/testdata/cfg_nd_keys.toml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
ND_ADDRESS = "127.0.0.1"
|
||||
ND_PORT = 4531
|
||||
ND_SCANNER_SCHEDULE = "@every 1h"
|
||||
@ -85,11 +85,9 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
UICoverArtSize = 600
|
||||
DefaultUICoverArtSize = 300
|
||||
)
|
||||
|
||||
var CacheWarmerImageSizes = []int{UICoverArtSize}
|
||||
|
||||
// Prometheus options
|
||||
const (
|
||||
PrometheusDefaultPath = "/metrics"
|
||||
|
||||
@ -380,24 +380,24 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
When("Square is false", func() {
|
||||
It("returns WebP even if original image is a PNG", func() {
|
||||
It("returns PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns WebP if original image is not a PNG", func() {
|
||||
It("returns JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
@ -430,24 +430,51 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(img.Bounds().Size().X).To(Equal(size))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||
},
|
||||
Entry("portrait png image", "png", "webp", false, 200),
|
||||
Entry("landscape png image", "png", "webp", true, 200),
|
||||
Entry("portrait jpg image", "jpg", "webp", false, 200),
|
||||
Entry("landscape jpg image", "jpg", "webp", true, 200),
|
||||
Entry("portrait png image", "png", "png", false, 200),
|
||||
Entry("landscape png image", "png", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", "png", false, 200),
|
||||
Entry("landscape jpg image", "jpg", "png", true, 200),
|
||||
)
|
||||
})
|
||||
When("DevJpegCoverArt is true and square is false", func() {
|
||||
When("EnableWebPEncoding is true and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevJpegCoverArt = true
|
||||
conf.Server.EnableWebPEncoding = true
|
||||
})
|
||||
It("returns JPEG even if original image is a PNG", func() {
|
||||
It("returns WebP even if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns WebP if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("EnableWebPEncoding is false and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
@ -463,11 +490,11 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("DevJpegCoverArt is true and square is true", func() {
|
||||
When("EnableWebPEncoding is false and square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevJpegCoverArt = true
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns PNG for square mode", func() {
|
||||
dirName := createImage("png", false, 200)
|
||||
|
||||
@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@ -24,7 +23,7 @@ type CacheWarmer interface {
|
||||
|
||||
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background
|
||||
// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original
|
||||
// image size, as well as the size defined in the UICoverArtSize constant.
|
||||
// image size, as well as the size defined by the UICoverArtSize config option.
|
||||
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
// If image cache is disabled, return a NOOP implementation
|
||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||
@ -38,10 +37,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
coverArtSize: conf.Server.UICoverArtSize,
|
||||
}
|
||||
|
||||
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
|
||||
@ -51,11 +51,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
}
|
||||
|
||||
type cacheWarmer struct {
|
||||
artwork Artwork
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
artwork Artwork
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
coverArtSize int
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
@ -142,16 +143,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, size := range consts.CacheWarmerImageSizes {
|
||||
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||
}
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
r.Close()
|
||||
return err
|
||||
size := a.coverArtSize
|
||||
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||
}
|
||||
return nil
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
r.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
func NoopCacheWarmer() CacheWarmer {
|
||||
|
||||
@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -182,7 +181,7 @@ var _ = Describe("CacheWarmer", func() {
|
||||
|
||||
Eventually(func() []int {
|
||||
return aw.getCachedSizes()
|
||||
}).Should(ContainElements(consts.UICoverArtSize))
|
||||
}).Should(ContainElements(conf.Server.UICoverArtSize))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -61,7 +61,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
||||
func (a *albumArtworkReader) Key() string {
|
||||
hashInput := conf.Server.CoverArtPriority
|
||||
if conf.Server.EnableExternalServices {
|
||||
hashInput += conf.Server.Agents
|
||||
hashInput = conf.Server.Agents + hashInput
|
||||
}
|
||||
hash := md5.Sum([]byte(hashInput))
|
||||
return fmt.Sprintf(
|
||||
|
||||
@ -19,6 +19,16 @@ import (
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if err := webp.Dynamic(); err != nil {
|
||||
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
|
||||
} else {
|
||||
log.Debug("Using native libwebp for WebP encoding/decoding")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
@ -98,28 +108,26 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader
|
||||
return nil, 0, fmt.Errorf("reading image data: %w", err)
|
||||
}
|
||||
|
||||
// Preserve animation for animated images (skip for square thumbnails)
|
||||
if !a.square {
|
||||
if isAnimatedGIF(data) {
|
||||
if a.a.ffmpeg.IsAvailable() {
|
||||
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
|
||||
r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality)
|
||||
if err == nil {
|
||||
return r, 0, nil
|
||||
}
|
||||
log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
|
||||
// Preserve animation for animated images
|
||||
if isAnimatedGIF(data) {
|
||||
if a.a.ffmpeg.IsAvailable() {
|
||||
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
|
||||
r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality)
|
||||
if err == nil {
|
||||
return r, 0, nil
|
||||
}
|
||||
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
|
||||
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
|
||||
return bytes.NewReader(data), 0, nil
|
||||
log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
|
||||
}
|
||||
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
|
||||
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
|
||||
return bytes.NewReader(data), 0, nil
|
||||
}
|
||||
|
||||
return resizeStaticImage(data, a.size, a.square)
|
||||
}
|
||||
|
||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
original, _, err := image.Decode(bytes.NewReader(data))
|
||||
original, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@ -159,14 +167,12 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
|
||||
|
||||
buf := bufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
if conf.Server.DevJpegCoverArt {
|
||||
if square {
|
||||
err = png.Encode(buf, dst)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
||||
}
|
||||
} else {
|
||||
if conf.Server.EnableWebPEncoding {
|
||||
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
|
||||
} else if format == "png" || square {
|
||||
err = png.Encode(buf, dst)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
||||
}
|
||||
if err != nil {
|
||||
bufPool.Put(buf)
|
||||
|
||||
@ -54,17 +54,17 @@ var _ = Describe("resizeImage", func() {
|
||||
Expect(len(output)).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("skips animation for square thumbnails even with animated GIF", func() {
|
||||
It("preserves animation for square thumbnails with animated GIF", func() {
|
||||
r.square = true
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Should fall through to static resize (not ffmpeg conversion)
|
||||
// The minimal test GIF may or may not resize successfully,
|
||||
// but ffmpeg should NOT have been called for animated conversion
|
||||
_ = result
|
||||
_ = err
|
||||
// Verify by checking the mock wasn't used for animated conversion:
|
||||
// If ffmpeg was called, it would return mock data, not static resize result
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should have been processed by ffmpeg (mock returns input data)
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
})
|
||||
|
||||
@ -81,13 +81,17 @@ var _ = Describe("resizeImage", func() {
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
|
||||
It("does not passthrough animated WebP for square thumbnails", func() {
|
||||
It("preserves animated WebP for square thumbnails", func() {
|
||||
r.square = true
|
||||
data := createAnimatedWebPBytes()
|
||||
// Should fall through to static resize, which will fail on fake WebP data
|
||||
_, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Static decode will fail on our minimal test WebP bytes (not a real image)
|
||||
Expect(err).To(HaveOccurred())
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
})
|
||||
|
||||
@ -104,15 +108,17 @@ var _ = Describe("resizeImage", func() {
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
|
||||
It("does not passthrough animated PNG for square thumbnails", func() {
|
||||
It("preserves animated PNG for square thumbnails", func() {
|
||||
r.square = true
|
||||
data := createAPNGBytes()
|
||||
// Should fall through to static resize
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Static PNG decode should succeed on our APNG (it's a valid PNG)
|
||||
if err == nil {
|
||||
Expect(result).ToNot(BeNil())
|
||||
}
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
10
core/external/provider.go
vendored
10
core/external/provider.go
vendored
@ -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 == "" {
|
||||
|
||||
65
core/external/provider_artistimage_test.go
vendored
65
core/external/provider_artistimage_test.go
vendored
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -193,6 +193,10 @@ 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.EnableWebPEncoding = conf.Server.EnableWebPEncoding
|
||||
data.Config.UICoverArtSize = conf.Server.UICoverArtSize
|
||||
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
||||
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||
|
||||
@ -63,6 +63,10 @@ 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"`
|
||||
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
|
||||
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
|
||||
8
go.mod
8
go.mod
@ -36,12 +36,12 @@ require (
|
||||
github.com/kardianos/service v1.2.4
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
github.com/mattn/go-sqlite3 v1.14.38
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pelletier/go-toml/v2 v2.3.0
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pressly/goose/v3 v3.27.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@ -58,7 +58,7 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.senan.xyz/taglib v0.11.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/image v0.38.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
@ -104,7 +104,7 @@ require (
|
||||
github.com/lestrrat-go/dsig v1.0.0 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/maruel/natural v1.3.0 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
|
||||
16
go.sum
16
go.sum
@ -167,8 +167,8 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
@ -177,8 +177,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
|
||||
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
@ -199,8 +199,8 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@ -323,8 +323,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
|
||||
@ -35,6 +35,7 @@ var fieldMap = map[string]*mappedField{
|
||||
"releasedate": {field: "media_file.release_date"},
|
||||
"size": {field: "media_file.size"},
|
||||
"compilation": {field: "media_file.compilation"},
|
||||
"missing": {field: "media_file.missing"},
|
||||
"explicitstatus": {field: "media_file.explicit_status"},
|
||||
"dateadded": {field: "media_file.created_at"},
|
||||
"datemodified": {field: "media_file.updated_at"},
|
||||
@ -49,9 +50,11 @@ var fieldMap = map[string]*mappedField{
|
||||
"catalognumber": {field: "media_file.catalog_num"},
|
||||
"filepath": {field: "media_file.path"},
|
||||
"filetype": {field: "media_file.suffix"},
|
||||
"codec": {field: "media_file.codec"},
|
||||
"duration": {field: "media_file.duration"},
|
||||
"bitrate": {field: "media_file.bit_rate"},
|
||||
"bitdepth": {field: "media_file.bit_depth"},
|
||||
"samplerate": {field: "media_file.sample_rate"},
|
||||
"bpm": {field: "media_file.bpm"},
|
||||
"channels": {field: "media_file.channels"},
|
||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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", "!!!!!!!", ""),
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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} λεπτά πριν"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,9 @@
|
||||
"bitDepth": "Bitprofundo",
|
||||
"sampleRate": "Elprena rapido",
|
||||
"missing": "Mankaj",
|
||||
"libraryName": "Biblioteko"
|
||||
"libraryName": "Biblioteko",
|
||||
"composer": "",
|
||||
"disc": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ludi Poste",
|
||||
@ -46,7 +48,8 @@
|
||||
"download": "Elŝuti",
|
||||
"playNext": "Ludu Poste",
|
||||
"info": "Akiri Informon",
|
||||
"showInPlaylist": "Montri en Ludlisto"
|
||||
"showInPlaylist": "Montri en Ludlisto",
|
||||
"instantMix": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -328,6 +331,82 @@
|
||||
"scanInProgress": "Skano progresas...",
|
||||
"noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"version": "Versio",
|
||||
"author": "Aŭtoro",
|
||||
"website": "Retejo",
|
||||
"permissions": "Permesoj",
|
||||
"enabled": "Ebligite",
|
||||
"status": "",
|
||||
"path": "Vojo",
|
||||
"lastError": "Eraro",
|
||||
"hasError": "Eraro",
|
||||
"updatedAt": "Ĝisdatigite",
|
||||
"createdAt": "",
|
||||
"configKey": "Ŝlosilo",
|
||||
"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": ""
|
||||
},
|
||||
"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": "Forigi ĉiujn mankajn dosierojn",
|
||||
"remove_all_missing_content": "Ĉu vi certas, ke vi volas forigi ĉiujn mankajn dosierojn de la datumbazo? Ĉi tio permanante forigos ĉiujn referencojn al ili, inkluzive iliajn ludnombrojn kaj taksojn.",
|
||||
"noSimilarSongsFound": "Neniuj similaj kantoj trovitaj",
|
||||
"noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj"
|
||||
"noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj",
|
||||
"startingInstantMix": "",
|
||||
"uploadCover": "",
|
||||
"removeCover": "",
|
||||
"coverUploaded": "",
|
||||
"coverRemoved": "",
|
||||
"coverUploadError": "",
|
||||
"coverRemoveError": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteko",
|
||||
@ -597,7 +683,8 @@
|
||||
"exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
|
||||
"exportFailed": "Malsukcesis kopii agordojn",
|
||||
"devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)",
|
||||
"devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj"
|
||||
"devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj",
|
||||
"downloadToml": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@
|
||||
"bitDepth": "Bit-sakonera",
|
||||
"sampleRate": "Lagin-tasa",
|
||||
"channels": "Kanalak",
|
||||
"disc": "%{discNumber}. diskoa",
|
||||
"discSubtitle": "Diskoaren azpititulua",
|
||||
"starred": "Gogokoa",
|
||||
"comment": "Iruzkina",
|
||||
@ -355,7 +356,8 @@
|
||||
"allUsers": "Baimendu erabiltzaile guztiak",
|
||||
"selectedUsers": "Hautatutako erabiltzaileak",
|
||||
"allLibraries": "Baimendu liburutegi guztiak",
|
||||
"selectedLibraries": "Hautatutako liburutegiak"
|
||||
"selectedLibraries": "Hautatutako liburutegiak",
|
||||
"allowWriteAccess": "Eman idazteko baimena"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Egoera",
|
||||
@ -400,6 +402,7 @@
|
||||
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
|
||||
"noLibraries": "Ez da liburutegirik hautatu",
|
||||
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
|
||||
"allowWriteAccessHelp": "Gaituta dagoenean, pluginak liburutegien direktorioko fitxategiak moldatu ditzake. Defektuz, pluginek bakarrik irakurtzeko baimena dute.",
|
||||
"requiredHosts": "Beharrezko ostatatzaileak"
|
||||
},
|
||||
"placeholders": {
|
||||
@ -554,6 +557,12 @@
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"uploadCover": "Igo azala",
|
||||
"removeCover": "Kendu azala",
|
||||
"coverUploaded": "Diskoaren azala eguneratu da",
|
||||
"coverRemoved": "Diskoaren azala kendu da",
|
||||
"coverUploadError": "Errorea diskoaren azala igotzean",
|
||||
"coverRemoveError": "Errorea diskoaren azala kentzean",
|
||||
"note": "OHARRA",
|
||||
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
|
||||
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
|
||||
@ -673,6 +682,7 @@
|
||||
"currentValue": "Uneko balioa",
|
||||
"configurationFile": "Konfigurazio-fitxategia",
|
||||
"exportToml": "Esportatu konfigurazioa (TOML)",
|
||||
"downloadToml": "Deskargatu konfigurazioa (TOML)",
|
||||
"exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan",
|
||||
"exportFailed": "Konfigurazioa kopiatzeak huts egin du",
|
||||
"devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.",
|
||||
|
||||
@ -37,7 +37,8 @@
|
||||
"sampleRate": "Sample waarde",
|
||||
"missing": "Ontbrekend",
|
||||
"libraryName": "Bibliotheek",
|
||||
"composer": ""
|
||||
"composer": "Componist",
|
||||
"disc": "Schijf %{discNumber}"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
@ -48,7 +49,7 @@
|
||||
"playNext": "Volgende",
|
||||
"info": "Meer info",
|
||||
"showInPlaylist": "Toon in afspeellijst",
|
||||
"instantMix": ""
|
||||
"instantMix": "Instant mix"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -350,10 +351,11 @@
|
||||
"createdAt": "Geinstalleerd",
|
||||
"configKey": "Sleutel",
|
||||
"configValue": "Waarde",
|
||||
"allUsers": "Alle gebruikers toelaten",
|
||||
"allUsers": "Sta toe voor alle gebruikers",
|
||||
"selectedUsers": "Geselecteerde gebruikers",
|
||||
"allLibraries": "Alle bibliotheken toestaan",
|
||||
"selectedLibraries": "Geselecteerde bibliotheken"
|
||||
"allLibraries": "Sta toe voor alle bibliotheken",
|
||||
"selectedLibraries": "Geselecteerde bibliotheken",
|
||||
"allowWriteAccess": "Sta schrijftoegang toe"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
@ -379,26 +381,27 @@
|
||||
"notifications": {
|
||||
"enabled": "Plugin actief",
|
||||
"disabled": "Plugin niet actief",
|
||||
"updated": "Plugin geupdate",
|
||||
"updated": "Plugin bijgewerkt",
|
||||
"error": "Fout bij updaten plugin"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Configuratie moet geldige JSON zijn"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "",
|
||||
"configHelp": "Configureer de plug-in met key-value paren. Leeglaten als de plug-in niet geconfigueerd hoeft te worden.",
|
||||
"clickPermissions": "Klik op permissie voor details",
|
||||
"noConfig": "Geen configuratie ingesteld",
|
||||
"allUsersHelp": "",
|
||||
"allUsersHelp": "Als dit aanstaat heeft de plug-in toegang tot alle gebruikers, inclusief toekomstige.",
|
||||
"noUsers": "Geen gebruikers geselecteerd",
|
||||
"permissionReason": "Reden",
|
||||
"usersRequired": "",
|
||||
"allLibrariesHelp": "",
|
||||
"usersRequired": "Deze plug-in heeft toegang nodig tot gebruikersinformatie. Selecteer welke gebruikers de plug-in toegang toe heeft, of schakel 'sta toe voor alle gebruikers' in.",
|
||||
"allLibrariesHelp": "Als dit aanstaat, heeft de plug-in toegang tot alle bibliotheken, inclusief toekomstige.",
|
||||
"noLibraries": "Geen bibliotheken geselecteerd",
|
||||
"librariesRequired": "",
|
||||
"librariesRequired": "Deze plug-in heeft toegang nodig tot bibliotheek informatie. Selecteer welke bibliotheken de plug-in toegang to heeft, of schakel 'sta toe voor alle bibliotheken' in.",
|
||||
"requiredHosts": "Benodigde hosts",
|
||||
"configValidationError": "",
|
||||
"schemaRenderError": ""
|
||||
"configValidationError": "Configuratiecheck mislukt",
|
||||
"schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.",
|
||||
"allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "Sleutel",
|
||||
@ -588,7 +591,13 @@
|
||||
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
|
||||
"noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
|
||||
"noTopSongsFound": "Geen beste nummers gevonden",
|
||||
"startingInstantMix": ""
|
||||
"startingInstantMix": "Laden van Instant mix...",
|
||||
"uploadCover": "Albumhoes toevoegen",
|
||||
"removeCover": "Verwijder albumhoes",
|
||||
"coverUploaded": "Albumhoes bijgewerkt",
|
||||
"coverRemoved": "Albumhoes verwijderd",
|
||||
"coverUploadError": "Fout bij het toevoegen albumhoes",
|
||||
"coverRemoveError": "Fout bij verwijderen albumhoes"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
@ -674,7 +683,8 @@
|
||||
"exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
|
||||
"exportFailed": "Kopiëren van configuratie mislukt",
|
||||
"devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)",
|
||||
"devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd"
|
||||
"devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd",
|
||||
"downloadToml": "Download configuratie (TOML)"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
"missing": "遺失",
|
||||
"libraryName": "媒體庫",
|
||||
"composer": "作曲者",
|
||||
"disc": ""
|
||||
"disc": "光碟 %{discNumber}"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "加入至播放佇列",
|
||||
@ -718,4 +718,4 @@
|
||||
"empty": "無播放內容",
|
||||
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/publicurl"
|
||||
@ -81,7 +82,7 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s
|
||||
|
||||
func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share {
|
||||
s.URL = ShareURL(r, s.ID)
|
||||
s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), consts.UICoverArtSize)
|
||||
s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize)
|
||||
for i := range s.Tracks {
|
||||
s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID)
|
||||
}
|
||||
|
||||
@ -55,13 +55,14 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
||||
"defaultLanguage": conf.Server.DefaultLanguage,
|
||||
"defaultUIVolume": conf.Server.DefaultUIVolume,
|
||||
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
|
||||
"uiCoverArtSize": conf.Server.UICoverArtSize,
|
||||
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
||||
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
||||
"gaTrackingId": conf.Server.GATrackingID,
|
||||
"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,
|
||||
|
||||
@ -86,6 +86,7 @@ var _ = Describe("serveIndex", func() {
|
||||
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
|
||||
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
|
||||
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
|
||||
Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)),
|
||||
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
||||
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
||||
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
||||
|
||||
@ -159,6 +159,10 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response
|
||||
}
|
||||
|
||||
func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubsonicPlaylist {
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
if ok && isClientInList(conf.Server.Subsonic.LegacyClients, player.Client) {
|
||||
return nil
|
||||
}
|
||||
pls := responses.OpenSubsonicPlaylist{}
|
||||
|
||||
if p.IsSmartPlaylist() {
|
||||
|
||||
@ -128,6 +128,23 @@ var _ = Describe("buildPlaylist", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("with legacy client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.LegacyClients = "legacy-client"
|
||||
player := model.Player{Client: "legacy-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all standard fields but no OpenSubsonic extensions", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
Expect(result.OpenSubsonicPlaylist).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no player in context", func() {
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
@ -213,6 +230,23 @@ var _ = Describe("buildPlaylist", func() {
|
||||
Expect(result.ValidUntil).To(Equal(&validUntil))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with legacy client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.LegacyClients = "legacy-client"
|
||||
player := model.Player{Client: "legacy-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns standard fields but no OpenSubsonic extensions", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
Expect(result.OpenSubsonicPlaylist).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -75,8 +75,12 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro
|
||||
continue
|
||||
}
|
||||
// Add coverArt if not legacy client
|
||||
var coverArt string
|
||||
if g.UploadedImage != "" {
|
||||
coverArt = g.CoverArtID().String()
|
||||
}
|
||||
res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{
|
||||
CoverArt: g.UploadedImage,
|
||||
CoverArt: coverArt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -71,7 +71,7 @@ var _ = Describe("Radio", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.InternetRadioStations.Radios).To(HaveLen(2))
|
||||
Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil())
|
||||
Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("rd-1_cover.jpg"))
|
||||
Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0"))
|
||||
Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil())
|
||||
Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty())
|
||||
})
|
||||
@ -129,7 +129,7 @@ var _ = Describe("Radio", func() {
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil())
|
||||
Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("rd-1_cover.jpg"))
|
||||
Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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) }),
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
2759
ui/package-lock.json
generated
2759
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -59,7 +59,7 @@
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^17.0.89",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import config from '../config'
|
||||
import 'react-image-lightbox/style.css'
|
||||
import subsonic from '../subsonic'
|
||||
import {
|
||||
@ -32,7 +32,6 @@ import {
|
||||
useAlbumsPerPage,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import { formatFullDate, intersperse } from '../utils'
|
||||
import AlbumExternalLinks from './AlbumExternalLinks'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
@ -255,7 +254,7 @@ const AlbumDetails = (props) => {
|
||||
})
|
||||
}, [record])
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE)
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
|
||||
return (
|
||||
|
||||
@ -18,8 +18,10 @@ import {
|
||||
PlayButton,
|
||||
ArtistLinkField,
|
||||
OverflowTooltip,
|
||||
useImageUrl,
|
||||
} from '../common'
|
||||
import { COVER_ART_SIZE, DraggableTypes } from '../consts'
|
||||
import config from '../config'
|
||||
import { DraggableTypes } from '../consts'
|
||||
import clsx from 'clsx'
|
||||
import { AlbumDatesField } from './AlbumDatesField.jsx'
|
||||
|
||||
@ -105,7 +107,7 @@ const useCoverStyles = makeStyles({
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
},
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
opacity: 0,
|
||||
},
|
||||
})
|
||||
|
||||
@ -125,8 +127,6 @@ const Cover = withContentRect('bounds')(({
|
||||
// Force height to be the same as the width determined by the GridList
|
||||
// noinspection JSSuspiciousNameCombination
|
||||
const classes = useCoverStyles({ height: contentRect.bounds.width })
|
||||
const [imageLoading, setImageLoading] = React.useState(true)
|
||||
const [imageError, setImageError] = React.useState(false)
|
||||
const [, dragAlbumRef] = useDrag(
|
||||
() => ({
|
||||
type: DraggableTypes.ALBUM,
|
||||
@ -136,32 +136,16 @@ const Cover = withContentRect('bounds')(({
|
||||
[record],
|
||||
)
|
||||
|
||||
// Reset image state when record changes
|
||||
React.useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const handleImageLoad = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(false)
|
||||
}, [])
|
||||
|
||||
const handleImageError = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}, [])
|
||||
const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
|
||||
const { imgUrl, loading: imageLoading } = useImageUrl(url)
|
||||
|
||||
return (
|
||||
<div ref={measureRef} className={classes.coverContainer}>
|
||||
<div ref={dragAlbumRef}>
|
||||
<img
|
||||
key={record.id} // Force re-render when record changes
|
||||
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)}
|
||||
src={imgUrl || undefined}
|
||||
alt={record.name}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -175,12 +175,12 @@ const AlbumListTitle = ({ albumListType }) => {
|
||||
return <Title subTitle={title} args={{ smart_count: 2 }} />
|
||||
}
|
||||
|
||||
const AlbumListPagination = (props) => {
|
||||
const AlbumListPagination = ({ albumListType, ...rest }) => {
|
||||
const { loading } = useListContext()
|
||||
if (loading) {
|
||||
if (loading && albumListType === 'random') {
|
||||
return null
|
||||
}
|
||||
return <Pagination {...props} />
|
||||
return <Pagination {...rest} />
|
||||
}
|
||||
|
||||
const randomStartingSeed = Math.random().toString()
|
||||
@ -243,7 +243,12 @@ const AlbumList = (props) => {
|
||||
actions={<AlbumListActions />}
|
||||
filters={<AlbumFilter />}
|
||||
perPage={perPage}
|
||||
pagination={<AlbumListPagination rowsPerPageOptions={perPageOptions} />}
|
||||
pagination={
|
||||
<AlbumListPagination
|
||||
rowsPerPageOptions={perPageOptions}
|
||||
albumListType={albumListType}
|
||||
/>
|
||||
}
|
||||
title={<AlbumListTitle albumListType={albumListType} />}
|
||||
>
|
||||
{albumView.grid ? (
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||
import AlbumInfo from '../album/AlbumInfo'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
|
||||
@ -110,7 +109,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
<CardMedia
|
||||
key={record.id}
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
|
||||
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
|
||||
@ -113,7 +112,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
<CardMedia
|
||||
key={record.id}
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
|
||||
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
|
||||
@ -2,14 +2,18 @@ import { useRecordContext } from 'react-admin'
|
||||
import { Avatar } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import clsx from 'clsx'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import config from '../config'
|
||||
import subsonic from '../subsonic'
|
||||
import { useImageUrl } from './useImageUrl'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
avatar: {
|
||||
width: '55px',
|
||||
height: '55px',
|
||||
},
|
||||
avatarEmpty: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
square: {
|
||||
borderRadius: '4px',
|
||||
},
|
||||
@ -22,15 +26,26 @@ export const CoverArtAvatar = ({
|
||||
const classes = useStyles()
|
||||
const recordContext = useRecordContext()
|
||||
const record = recordProp || recordContext
|
||||
if (!record) return null
|
||||
const square = variant !== 'circular'
|
||||
const url = record
|
||||
? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square)
|
||||
: null
|
||||
const { imgUrl } = useImageUrl(url)
|
||||
if (!record) return null
|
||||
return (
|
||||
<Avatar
|
||||
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)}
|
||||
src={imgUrl || undefined}
|
||||
variant={variant}
|
||||
className={clsx(classes.avatar, square && classes.square)}
|
||||
className={clsx(
|
||||
classes.avatar,
|
||||
square && classes.square,
|
||||
!imgUrl && classes.avatarEmpty,
|
||||
)}
|
||||
alt={record.name}
|
||||
/>
|
||||
>
|
||||
{/* Empty child prevents default person icon while loading */}
|
||||
{!imgUrl && <span />}
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -46,3 +46,4 @@ export * from './useSearchRefocus'
|
||||
export * from './ImageUploadOverlay'
|
||||
export * from './CoverArtAvatar'
|
||||
export * from './useImageLoadingState'
|
||||
export * from './useImageUrl'
|
||||
|
||||
144
ui/src/common/useImageUrl.js
Normal file
144
ui/src/common/useImageUrl.js
Normal file
@ -0,0 +1,144 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
|
||||
// Persists across component mount/unmount cycles so that
|
||||
// React Admin refreshes (which remount list items) don't re-fetch images.
|
||||
const cache = new Map()
|
||||
const MAX_CACHE_SIZE = 300
|
||||
|
||||
// Limit concurrent fetches to leave browser connections free for API requests.
|
||||
// Browsers allow ~6 connections per origin on HTTP/1.1; reserving 2 for API
|
||||
// calls prevents image fetches from blocking pagination/data requests.
|
||||
const MAX_CONCURRENT = 4
|
||||
let activeFetches = 0
|
||||
const pendingQueue = []
|
||||
|
||||
const processQueue = () => {
|
||||
while (pendingQueue.length > 0 && activeFetches < MAX_CONCURRENT) {
|
||||
const next = pendingQueue.shift()
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
// Evicts oldest unused entries (Map iterates in insertion order).
|
||||
const evictIfNeeded = () => {
|
||||
if (cache.size <= MAX_CACHE_SIZE) return
|
||||
for (const [key, entry] of cache) {
|
||||
if (cache.size <= MAX_CACHE_SIZE) break
|
||||
if (entry.refCount === 0) {
|
||||
if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl)
|
||||
cache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an image via fetch() with AbortController so that in-flight requests
|
||||
* are canceled on unmount (e.g., during pagination). Uses a module-level cache
|
||||
* so remounting returns the cached blob URL instantly.
|
||||
*/
|
||||
export const useImageUrl = (url) => {
|
||||
const cached = url ? cache.get(url) : null
|
||||
const [imgUrl, setImgUrl] = useState(cached?.blobUrl || null)
|
||||
const [loading, setLoading] = useState(!!url && !cached)
|
||||
const [error, setError] = useState(cached?.error || false)
|
||||
const abortedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
abortedRef.current = false
|
||||
|
||||
if (!url) {
|
||||
setImgUrl(null)
|
||||
setLoading(false)
|
||||
setError(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Re-check: another component's effect may have populated the cache
|
||||
// between this component's render and effect execution.
|
||||
const entry = cache.get(url)
|
||||
if (entry) {
|
||||
entry.refCount++
|
||||
setImgUrl(entry.blobUrl)
|
||||
setLoading(false)
|
||||
setError(entry.error || false)
|
||||
return () => {
|
||||
entry.refCount--
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
let queued = true
|
||||
setImgUrl(null)
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
|
||||
const doFetch = () => {
|
||||
queued = false
|
||||
activeFetches++
|
||||
fetch(url, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
return res.blob()
|
||||
})
|
||||
.then((blob) => {
|
||||
activeFetches--
|
||||
processQueue()
|
||||
// Guard against late resolution after abort
|
||||
if (abortedRef.current) {
|
||||
return
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
// Handle concurrent fetches: if another component already cached
|
||||
// this URL, use its entry and discard our blob.
|
||||
const existing = cache.get(url)
|
||||
if (existing && existing.blobUrl) {
|
||||
existing.refCount++
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
setImgUrl(existing.blobUrl)
|
||||
} else {
|
||||
cache.set(url, { blobUrl: objectUrl, refCount: 1 })
|
||||
evictIfNeeded()
|
||||
setImgUrl(objectUrl)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
activeFetches--
|
||||
processQueue()
|
||||
if (err.name === 'AbortError') {
|
||||
return // Expected on unmount or URL change
|
||||
}
|
||||
// Cache the error so repeated mounts don't re-fetch broken URLs
|
||||
cache.set(url, { blobUrl: null, error: true, refCount: 0 })
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
if (activeFetches < MAX_CONCURRENT) {
|
||||
queued = false
|
||||
doFetch()
|
||||
} else {
|
||||
pendingQueue.push(doFetch)
|
||||
}
|
||||
|
||||
return () => {
|
||||
abortedRef.current = true
|
||||
if (queued) {
|
||||
// Remove from queue if not yet started
|
||||
const idx = pendingQueue.indexOf(doFetch)
|
||||
if (idx !== -1) pendingQueue.splice(idx, 1)
|
||||
} else {
|
||||
controller.abort()
|
||||
}
|
||||
const entry = cache.get(url)
|
||||
if (entry) {
|
||||
entry.refCount--
|
||||
}
|
||||
}
|
||||
}, [url])
|
||||
|
||||
return { imgUrl, loading, error }
|
||||
}
|
||||
234
ui/src/common/useImageUrl.test.js
Normal file
234
ui/src/common/useImageUrl.test.js
Normal file
@ -0,0 +1,234 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks'
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Helper to flush all pending promises
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
// We need a fresh module for each test to reset the module-level cache
|
||||
let useImageUrl
|
||||
|
||||
describe('useImageUrl', () => {
|
||||
let abortSpy
|
||||
let OriginalAbortController
|
||||
let originalCreateObjectURL
|
||||
let originalRevokeObjectURL
|
||||
let originalFetch
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset module to clear the cache
|
||||
vi.resetModules()
|
||||
const mod = await import('./useImageUrl')
|
||||
useImageUrl = mod.useImageUrl
|
||||
|
||||
abortSpy = vi.fn()
|
||||
OriginalAbortController = global.AbortController
|
||||
originalCreateObjectURL = global.URL.createObjectURL
|
||||
originalRevokeObjectURL = global.URL.revokeObjectURL
|
||||
originalFetch = global.fetch
|
||||
|
||||
global.AbortController = function () {
|
||||
this.signal = 'mock-signal'
|
||||
this.abort = abortSpy
|
||||
}
|
||||
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.AbortController = OriginalAbortController
|
||||
global.URL.createObjectURL = originalCreateObjectURL
|
||||
global.URL.revokeObjectURL = originalRevokeObjectURL
|
||||
global.fetch = originalFetch
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should return null values when url is null', () => {
|
||||
const { result } = renderHook(() => useImageUrl(null))
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.imgUrl).toBeNull()
|
||||
expect(result.current.error).toBe(false)
|
||||
})
|
||||
|
||||
it('should return loading state initially', () => {
|
||||
global.fetch = vi.fn(() => new Promise(() => {}))
|
||||
const { result } = renderHook(() =>
|
||||
useImageUrl('http://example.com/img.jpg'),
|
||||
)
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
expect(result.current.imgUrl).toBeNull()
|
||||
expect(result.current.error).toBe(false)
|
||||
})
|
||||
|
||||
it('should fetch image and return blob URL on success', async () => {
|
||||
const mockBlob = new Blob(['image-data'], { type: 'image/png' })
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageUrl('http://example.com/img.jpg'),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.imgUrl).toBe('blob:mock-url')
|
||||
expect(result.current.error).toBe(false)
|
||||
expect(global.fetch).toHaveBeenCalledWith('http://example.com/img.jpg', {
|
||||
signal: 'mock-signal',
|
||||
})
|
||||
})
|
||||
|
||||
it('should set error on HTTP failure', async () => {
|
||||
global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 }))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageUrl('http://example.com/missing.jpg'),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.imgUrl).toBeNull()
|
||||
expect(result.current.error).toBe(true)
|
||||
})
|
||||
|
||||
it('should abort fetch on unmount', async () => {
|
||||
global.fetch = vi.fn(() => new Promise(() => {}))
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useImageUrl('http://example.com/img.jpg'),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(abortSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should abort previous fetch when URL changes', async () => {
|
||||
const abortSpies = []
|
||||
global.AbortController = function () {
|
||||
const spy = vi.fn()
|
||||
abortSpies.push(spy)
|
||||
this.signal = `signal-${abortSpies.length}`
|
||||
this.abort = spy
|
||||
}
|
||||
|
||||
const mockBlob = new Blob(['data'], { type: 'image/png' })
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
}),
|
||||
)
|
||||
|
||||
const { rerender } = renderHook(({ url }) => useImageUrl(url), {
|
||||
initialProps: { url: 'http://example.com/img1.jpg' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
// Change URL - should abort the first controller
|
||||
rerender({ url: 'http://example.com/img2.jpg' })
|
||||
|
||||
expect(abortSpies[0]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not set error on AbortError', async () => {
|
||||
const abortError = new DOMException('Aborted', 'AbortError')
|
||||
global.fetch = vi.fn(() => Promise.reject(abortError))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageUrl('http://example.com/img.jpg'),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe(false)
|
||||
})
|
||||
|
||||
it('should use cached blob URL on remount without re-fetching', async () => {
|
||||
const mockBlob = new Blob(['data'], { type: 'image/png' })
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
}),
|
||||
)
|
||||
|
||||
// First mount — fetches and caches
|
||||
const { unmount } = renderHook(() =>
|
||||
useImageUrl('http://example.com/img.jpg'),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Unmount (simulates React Admin refresh)
|
||||
unmount()
|
||||
|
||||
// Remount with same URL — should use cache
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useImageUrl('http://example.com/img.jpg'),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
// Should NOT have fetched again
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
expect(result2.current.imgUrl).toBe('blob:mock-url')
|
||||
expect(result2.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('should cache errors and not re-fetch broken URLs', async () => {
|
||||
global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 }))
|
||||
|
||||
// First mount — fetch fails and error is cached
|
||||
const { unmount } = renderHook(() =>
|
||||
useImageUrl('http://example.com/broken.jpg'),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
unmount()
|
||||
|
||||
// Remount with same URL — should use cached error, not re-fetch
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useImageUrl('http://example.com/broken.jpg'),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
expect(result2.current.error).toBe(true)
|
||||
expect(result2.current.imgUrl).toBeNull()
|
||||
expect(result2.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -21,8 +21,9 @@ const defaultConfig = {
|
||||
defaultLanguage: '',
|
||||
defaultUIVolume: 100,
|
||||
uiSearchDebounceMs: 200,
|
||||
uiCoverArtSize: 600,
|
||||
enableUserEditing: true,
|
||||
enableCoverArtUpload: true,
|
||||
enableArtworkUpload: true,
|
||||
enableSharing: true,
|
||||
shareURL: '',
|
||||
defaultDownloadableShare: true,
|
||||
|
||||
@ -26,8 +26,6 @@ DraggableTypes.ALL.push(
|
||||
|
||||
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
|
||||
|
||||
export const COVER_ART_SIZE = 600
|
||||
|
||||
export const DEFAULT_SHARE_BITRATE = 128
|
||||
|
||||
export const BITRATE_CHOICES = [
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
OverflowTooltip,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import config from '../config'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
@ -107,7 +107,7 @@ const PlaylistDetails = (props) => {
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
|
||||
return (
|
||||
|
||||
@ -11,7 +11,8 @@ import { makeStyles } from '@material-ui/core/styles'
|
||||
import { urlValidate } from '../utils/validations'
|
||||
import { Title, ImageUploadOverlay, useImageLoadingState } from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||
import config from '../config'
|
||||
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
coverParent: {
|
||||
@ -83,7 +84,7 @@ const RadioCoverArt = ({ record }) => {
|
||||
{record.uploadedImage ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)}
|
||||
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
|
||||
@ -14,8 +14,12 @@ import {
|
||||
UrlField,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import { List } from '../common'
|
||||
import { ToggleFieldsMenu, useSelectedFields } from '../common'
|
||||
import {
|
||||
List,
|
||||
useImageUrl,
|
||||
ToggleFieldsMenu,
|
||||
useSelectedFields,
|
||||
} from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
import { StreamField } from './StreamField'
|
||||
import { setTrack } from '../actions'
|
||||
@ -78,10 +82,12 @@ const RadioListActions = ({
|
||||
const avatarStyle = { width: 40, height: 40 }
|
||||
|
||||
const CoverArtField = ({ record }) => {
|
||||
if (!record) return null
|
||||
const src = record.uploadedImage
|
||||
const directUrl = record?.uploadedImage
|
||||
? subsonic.getCoverArtUrl(record, 40, true)
|
||||
: RADIO_PLACEHOLDER_IMAGE
|
||||
: null
|
||||
const { imgUrl } = useImageUrl(directUrl)
|
||||
if (!record) return null
|
||||
const src = imgUrl || RADIO_PLACEHOLDER_IMAGE
|
||||
return (
|
||||
<Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} />
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import subsonic from '../subsonic'
|
||||
import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||
import config from '../config'
|
||||
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||
|
||||
export async function songFromRadio(radio) {
|
||||
if (!radio) {
|
||||
@ -8,7 +9,7 @@ export async function songFromRadio(radio) {
|
||||
|
||||
let cover = RADIO_PLACEHOLDER_IMAGE
|
||||
if (radio.uploadedImage) {
|
||||
cover = subsonic.getCoverArtUrl(radio, COVER_ART_SIZE, true)
|
||||
cover = subsonic.getCoverArtUrl(radio, config.uiCoverArtSize, true)
|
||||
} else {
|
||||
// Try favicon as fallback
|
||||
try {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { vi } from 'vitest'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import config from '../config'
|
||||
import subsonic from './index'
|
||||
|
||||
describe('getCoverArtUrl', () => {
|
||||
@ -31,7 +31,11 @@ describe('getCoverArtUrl', () => {
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true)
|
||||
const url = subsonic.getCoverArtUrl(
|
||||
playlistRecord,
|
||||
config.uiCoverArtSize,
|
||||
true,
|
||||
)
|
||||
|
||||
expect(url).toContain('pl-playlist-123')
|
||||
expect(url).toContain('size=600')
|
||||
@ -45,7 +49,11 @@ describe('getCoverArtUrl', () => {
|
||||
sync: true,
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true)
|
||||
const url = subsonic.getCoverArtUrl(
|
||||
playlistRecord,
|
||||
config.uiCoverArtSize,
|
||||
true,
|
||||
)
|
||||
|
||||
expect(url).toContain('pl-playlist-123')
|
||||
expect(url).toContain('size=600')
|
||||
@ -60,7 +68,11 @@ describe('getCoverArtUrl', () => {
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(albumRecord, COVER_ART_SIZE, true)
|
||||
const url = subsonic.getCoverArtUrl(
|
||||
albumRecord,
|
||||
config.uiCoverArtSize,
|
||||
true,
|
||||
)
|
||||
|
||||
expect(url).toContain('al-album-123')
|
||||
expect(url).toContain('size=600')
|
||||
@ -74,7 +86,7 @@ describe('getCoverArtUrl', () => {
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(songRecord, COVER_ART_SIZE, true)
|
||||
const url = subsonic.getCoverArtUrl(songRecord, config.uiCoverArtSize, true)
|
||||
|
||||
expect(url).toContain('mf-song-123')
|
||||
expect(url).toContain('size=600')
|
||||
@ -87,7 +99,11 @@ describe('getCoverArtUrl', () => {
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(artistRecord, COVER_ART_SIZE, true)
|
||||
const url = subsonic.getCoverArtUrl(
|
||||
artistRecord,
|
||||
config.uiCoverArtSize,
|
||||
true,
|
||||
)
|
||||
|
||||
expect(url).toContain('ar-artist-123')
|
||||
expect(url).toContain('size=600')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user