mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
48 Commits
906086b929
...
37b5bf7f71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b5bf7f71 | ||
|
|
2018979bc3 | ||
|
|
e7c7cba873 | ||
|
|
93631cdee9 | ||
|
|
c87db92cee | ||
|
|
80c1e60259 | ||
|
|
23f3556371 | ||
|
|
c60637de24 | ||
|
|
220019a9f1 | ||
|
|
6109bf5192 | ||
|
|
4030bfe06f | ||
|
|
c5bb920b88 | ||
|
|
0f6a076dca | ||
|
|
420d2c8e5a | ||
|
|
9fe9cf3ff6 | ||
|
|
a293d12034 | ||
|
|
dc99994bdd | ||
|
|
049fc78177 | ||
|
|
2b041c02ad | ||
|
|
2588558946 | ||
|
|
f33ca75378 | ||
|
|
79e1af7cd6 | ||
|
|
ccee33f474 | ||
|
|
33e20d355e | ||
|
|
4c91936848 | ||
|
|
0a0f1779cb | ||
|
|
356b0716b6 | ||
|
|
8a19fa9991 | ||
|
|
221d301c42 | ||
|
|
4cca7bce4e | ||
|
|
d91b5e8f4d | ||
|
|
03608d3eef | ||
|
|
cb396f3dba | ||
|
|
400a079fcd | ||
|
|
03844a9a36 | ||
|
|
5cd1fcb492 | ||
|
|
a4c289b28c | ||
|
|
f7b60c7952 | ||
|
|
ba8d427890 | ||
|
|
3f7226d253 | ||
|
|
00b8fbd789 | ||
|
|
31d94acfe7 | ||
|
|
b5164c61ab | ||
|
|
a83ebd1c98 | ||
|
|
d2a54243a8 | ||
|
|
b013b71ba9 | ||
|
|
ad92b752be | ||
|
|
f39d75e7d2 |
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@ -338,7 +338,7 @@ jobs:
|
|||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Create manifest list and push to Docker Hub
|
- name: Create manifest list and push to Docker Hub
|
||||||
uses: nick-fields/retry@v3
|
uses: nick-fields/retry@v4
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 5
|
timeout_minutes: 5
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -38,3 +38,4 @@ AGENTS.md
|
|||||||
*.ndp
|
*.ndp
|
||||||
openspec/
|
openspec/
|
||||||
go.work*
|
go.work*
|
||||||
|
.worktrees/
|
||||||
@ -55,6 +55,7 @@ linters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
- node_modules
|
||||||
formatters:
|
formatters:
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
@ -62,3 +63,4 @@ formatters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
- node_modules
|
||||||
|
|||||||
37
Makefile
37
Makefile
@ -1,6 +1,8 @@
|
|||||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||||
NODE_VERSION=$(shell cat .nvmrc)
|
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
|
# Set global environment variables, required for most targets
|
||||||
export CGO_CFLAGS_ALLOW=--define-prefix
|
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||||
@ -233,6 +235,39 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
|
|||||||
.PHONY: get-music
|
.PHONY: get-music
|
||||||
|
|
||||||
|
|
||||||
|
##########################################
|
||||||
|
#### Worktrees
|
||||||
|
|
||||||
|
WORKTREES_DIR := .worktrees
|
||||||
|
|
||||||
|
wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1]
|
||||||
|
@if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi
|
||||||
|
@mkdir -p $(WORKTREES_DIR)
|
||||||
|
@echo "Creating worktree for branch '${name}'..."
|
||||||
|
@git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \
|
||||||
|
git worktree add $(WORKTREES_DIR)/${name} ${name}
|
||||||
|
@if [ -n "${go}" ]; then \
|
||||||
|
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \
|
||||||
|
else \
|
||||||
|
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \
|
||||||
|
fi
|
||||||
|
@echo "\nWorktree ready at $(WORKTREES_DIR)/${name}"
|
||||||
|
@echo " cd $(WORKTREES_DIR)/${name}"
|
||||||
|
.PHONY: wt
|
||||||
|
|
||||||
|
rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name
|
||||||
|
@if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi
|
||||||
|
@if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi
|
||||||
|
@echo "Removing worktree '${name}'..."
|
||||||
|
@git worktree remove --force $(WORKTREES_DIR)/${name}
|
||||||
|
@echo "Worktree '${name}' removed."
|
||||||
|
@echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}"
|
||||||
|
.PHONY: rm-wt
|
||||||
|
|
||||||
|
ls-wt: ##@Worktrees List all active git worktrees
|
||||||
|
@git worktree list
|
||||||
|
.PHONY: ls-wt
|
||||||
|
|
||||||
##########################################
|
##########################################
|
||||||
#### Miscellaneous
|
#### Miscellaneous
|
||||||
|
|
||||||
|
|||||||
@ -58,7 +58,20 @@ func (e extractor) Version() string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
|
||||||
|
// Recover from panics in the WASM runtime that can occur during any taglib
|
||||||
|
// operation (opening, reading tags, or reading properties). This catches crashes
|
||||||
|
// from malformed files or WASM runtime issues (e.g., wazero mmap failures on
|
||||||
|
// hardened systems with MemoryDenyWriteExecute=true).
|
||||||
|
debug.SetPanicOnFault(true)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
|
||||||
|
debug.PrintStack()
|
||||||
|
err = fmt.Errorf("WASM runtime panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
f, close, err := e.openFile(filePath)
|
f, close, err := e.openFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
|
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
|
||||||
@ -112,16 +125,6 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
|||||||
// openFile opens the file at filePath using the extractor's filesystem.
|
// openFile opens the file at filePath using the extractor's filesystem.
|
||||||
// It returns a TagLib File handle and a cleanup function to close resources.
|
// It returns a TagLib File handle and a cleanup function to close resources.
|
||||||
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
|
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
|
||||||
// Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory
|
|
||||||
// on hardened systems like NixOS with MemoryDenyWriteExecute=true)
|
|
||||||
debug.SetPanicOnFault(true)
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r)
|
|
||||||
err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Open the file from the filesystem
|
// Open the file from the filesystem
|
||||||
file, err := e.fs.Open(filePath)
|
file, err := e.fs.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -248,6 +248,7 @@ ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
|
|||||||
TimeoutStopSec=20
|
TimeoutStopSec=20
|
||||||
RestartSec=120
|
RestartSec=120
|
||||||
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
||||||
|
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
|
||||||
|
|
||||||
DevicePolicy=closed
|
DevicePolicy=closed
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
|
|||||||
@ -16,8 +16,8 @@ import (
|
|||||||
"github.com/kr/pretty"
|
"github.com/kr/pretty"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/scheduler"
|
||||||
"github.com/navidrome/navidrome/utils/run"
|
"github.com/navidrome/navidrome/utils/run"
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -70,6 +70,7 @@ type configOptions struct {
|
|||||||
MPVCmdTemplate string
|
MPVCmdTemplate string
|
||||||
CoverArtPriority string
|
CoverArtPriority string
|
||||||
CoverArtQuality int
|
CoverArtQuality int
|
||||||
|
EnableWebPEncoding bool
|
||||||
ArtistArtPriority string
|
ArtistArtPriority string
|
||||||
ArtistImageFolder string
|
ArtistImageFolder string
|
||||||
DiscArtPriority string
|
DiscArtPriority string
|
||||||
@ -78,7 +79,7 @@ type configOptions struct {
|
|||||||
EnableFavourites bool
|
EnableFavourites bool
|
||||||
EnableStarRating bool
|
EnableStarRating bool
|
||||||
EnableUserEditing bool
|
EnableUserEditing bool
|
||||||
EnableCoverArtUpload bool
|
EnableArtworkUpload bool
|
||||||
EnableSharing bool
|
EnableSharing bool
|
||||||
ShareURL string
|
ShareURL string
|
||||||
DefaultShareExpiration time.Duration
|
DefaultShareExpiration time.Duration
|
||||||
@ -87,6 +88,7 @@ type configOptions struct {
|
|||||||
DefaultLanguage string
|
DefaultLanguage string
|
||||||
DefaultUIVolume int
|
DefaultUIVolume int
|
||||||
UISearchDebounceMs int
|
UISearchDebounceMs int
|
||||||
|
UICoverArtSize int
|
||||||
EnableReplayGain bool
|
EnableReplayGain bool
|
||||||
EnableCoverAnimation bool
|
EnableCoverAnimation bool
|
||||||
EnableNowPlaying bool
|
EnableNowPlaying bool
|
||||||
@ -141,7 +143,6 @@ type configOptions struct {
|
|||||||
DevOptimizeDB bool
|
DevOptimizeDB bool
|
||||||
DevPreserveUnicodeInExternalCalls bool
|
DevPreserveUnicodeInExternalCalls bool
|
||||||
DevEnableMediaFileProbe bool
|
DevEnableMediaFileProbe bool
|
||||||
DevJpegCoverArt bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type scannerOptions struct {
|
type scannerOptions struct {
|
||||||
@ -258,6 +259,13 @@ type searchOptions struct {
|
|||||||
FullString bool
|
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 (
|
var (
|
||||||
Server = &configOptions{}
|
Server = &configOptions{}
|
||||||
hooks []func()
|
hooks []func()
|
||||||
@ -267,14 +275,14 @@ func LoadFromFile(confFile string) {
|
|||||||
viper.SetConfigFile(confFile)
|
viper.SetConfigFile(confFile)
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
|
logFatal("Error reading config file:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
Load(true)
|
Load(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(noConfigDump bool) {
|
func Load(noConfigDump bool) {
|
||||||
parseIniFileConfiguration()
|
parseIniFileConfiguration()
|
||||||
|
remapEnvVarKeysFromConfig()
|
||||||
|
|
||||||
// Map deprecated options to their new names for backwards compatibility
|
// Map deprecated options to their new names for backwards compatibility
|
||||||
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||||
@ -284,14 +292,12 @@ func Load(noConfigDump bool) {
|
|||||||
|
|
||||||
err := viper.Unmarshal(&Server)
|
err := viper.Unmarshal(&Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
logFatal("Error parsing config:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
|
logFatal("Error creating data path:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if Server.CacheFolder == "" {
|
if Server.CacheFolder == "" {
|
||||||
@ -299,14 +305,12 @@ func Load(noConfigDump bool) {
|
|||||||
}
|
}
|
||||||
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
|
logFatal("Error creating cache path:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
|
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err)
|
logFatal("Error creating artwork path:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if Server.Plugins.Enabled {
|
if Server.Plugins.Enabled {
|
||||||
@ -315,8 +319,7 @@ func Load(noConfigDump bool) {
|
|||||||
}
|
}
|
||||||
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
|
logFatal("Error creating plugins path:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -328,8 +331,7 @@ func Load(noConfigDump bool) {
|
|||||||
if Server.Backup.Path != "" {
|
if Server.Backup.Path != "" {
|
||||||
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
|
logFatal("Error creating backup path:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,13 +339,14 @@ func Load(noConfigDump bool) {
|
|||||||
if Server.LogFile != "" {
|
if Server.LogFile != "" {
|
||||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
|
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
log.SetOutput(out)
|
log.SetOutput(out)
|
||||||
} else if os.Getenv("JOURNAL_STREAM") != "" {
|
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
|
||||||
// When running under systemd, prepend syslog priority prefixes so
|
// When running under systemd, prepend syslog priority prefixes so
|
||||||
// journald assigns the correct severity to each log line.
|
// journald assigns the correct severity to each log line.
|
||||||
|
// Note that we have an additional environment variable, as JOURNAL_STREAM
|
||||||
|
// can be present in a systemd environment even if not running as a systemd service
|
||||||
log.EnableJournalFormat()
|
log.EnableJournalFormat()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -368,8 +371,7 @@ func Load(noConfigDump bool) {
|
|||||||
if Server.BaseURL != "" {
|
if Server.BaseURL != "" {
|
||||||
u, err := url.Parse(Server.BaseURL)
|
u, err := url.Parse(Server.BaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
|
logFatal("Invalid BaseURL:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
Server.BasePath = u.Path
|
Server.BasePath = u.Path
|
||||||
u.Path = ""
|
u.Path = ""
|
||||||
@ -423,6 +425,13 @@ func Load(noConfigDump bool) {
|
|||||||
// Removed options
|
// Removed options
|
||||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
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
|
// Call init hooks
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
hook()
|
hook()
|
||||||
@ -464,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
|
// 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.
|
// the config has been read by viper, but before unmarshalling it into the Config struct.
|
||||||
func mapDeprecatedOption(legacyName, newName string) {
|
func mapDeprecatedOption(legacyName, newName string) {
|
||||||
@ -481,18 +519,15 @@ func parseIniFileConfiguration() {
|
|||||||
var iniConfig map[string]any
|
var iniConfig map[string]any
|
||||||
err := viper.Unmarshal(&iniConfig)
|
err := viper.Unmarshal(&iniConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
logFatal("Error parsing config:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
cfg, ok := iniConfig["default"].(map[string]any)
|
cfg, ok := iniConfig["default"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
|
logFatal("Error parsing config: missing [default] section:", iniConfig)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
err = viper.MergeConfigMap(cfg)
|
err = viper.MergeConfigMap(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
logFatal("Error parsing config:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -570,15 +605,9 @@ func validateBackupSchedule() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateSchedule(schedule, field string) (string, error) {
|
func validateSchedule(schedule, field string) (string, error) {
|
||||||
if _, err := time.ParseDuration(schedule); err == nil {
|
_, err := scheduler.ParseCrontab(schedule)
|
||||||
schedule = "@every " + schedule
|
|
||||||
}
|
|
||||||
c := cron.New()
|
|
||||||
id, err := c.AddFunc(schedule, func() {})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
|
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
|
||||||
} else {
|
|
||||||
c.Remove(id)
|
|
||||||
}
|
}
|
||||||
return schedule, err
|
return schedule, err
|
||||||
}
|
}
|
||||||
@ -621,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
|
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
||||||
func AddHook(hook func()) {
|
func AddHook(hook func()) {
|
||||||
hooks = append(hooks, hook)
|
hooks = append(hooks, hook)
|
||||||
@ -676,10 +720,13 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
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("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("ffmpegpath", "")
|
||||||
|
viper.SetDefault("mpvpath", "")
|
||||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
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("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||||
viper.SetDefault("coverartquality", 75)
|
viper.SetDefault("coverartquality", 75)
|
||||||
|
viper.SetDefault("enablewebpencoding", false)
|
||||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||||
|
viper.SetDefault("artistimagefolder", "")
|
||||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
||||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||||
viper.SetDefault("enablegravatar", false)
|
viper.SetDefault("enablegravatar", false)
|
||||||
@ -690,10 +737,11 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("defaultlanguage", "")
|
viper.SetDefault("defaultlanguage", "")
|
||||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||||
|
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
|
||||||
viper.SetDefault("enablereplaygain", true)
|
viper.SetDefault("enablereplaygain", true)
|
||||||
viper.SetDefault("enablecoveranimation", true)
|
viper.SetDefault("enablecoveranimation", true)
|
||||||
viper.SetDefault("enablenowplaying", true)
|
viper.SetDefault("enablenowplaying", true)
|
||||||
viper.SetDefault("enablecoverartupload", true)
|
viper.SetDefault("enableartworkupload", true)
|
||||||
viper.SetDefault("enablesharing", false)
|
viper.SetDefault("enablesharing", false)
|
||||||
viper.SetDefault("shareurl", "")
|
viper.SetDefault("shareurl", "")
|
||||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||||
@ -758,6 +806,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("plugins.enabled", true)
|
viper.SetDefault("plugins.enabled", true)
|
||||||
viper.SetDefault("plugins.cachesize", "200MB")
|
viper.SetDefault("plugins.cachesize", "200MB")
|
||||||
viper.SetDefault("plugins.autoreload", false)
|
viper.SetDefault("plugins.autoreload", false)
|
||||||
|
viper.SetDefault("plugins.loglevel", "")
|
||||||
|
|
||||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||||
viper.SetDefault("devlogsourceline", false)
|
viper.SetDefault("devlogsourceline", false)
|
||||||
@ -771,7 +820,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("devuishowconfig", true)
|
viper.SetDefault("devuishowconfig", true)
|
||||||
viper.SetDefault("devneweventstream", true)
|
viper.SetDefault("devneweventstream", true)
|
||||||
viper.SetDefault("devoffsetoptimize", 50000)
|
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("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||||
@ -787,7 +836,6 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("devoptimizedb", true)
|
viper.SetDefault("devoptimizedb", true)
|
||||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||||
viper.SetDefault("devenablemediafileprobe", true)
|
viper.SetDefault("devenablemediafileprobe", true)
|
||||||
viper.SetDefault("devjpegcoverart", false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -824,8 +872,7 @@ func InitConfig(cfgFile string, loadEnvVars bool) {
|
|||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if viper.ConfigFileUsed() != "" && err != nil {
|
if viper.ConfigFileUsed() != "" && err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
|
logFatal("Navidrome could not open config file:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package conf_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -24,6 +25,11 @@ var _ = Describe("Configuration", func() {
|
|||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||||
viper.SetDefault("loglevel", "error")
|
viper.SetDefault("loglevel", "error")
|
||||||
conf.ResetConf()
|
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() {
|
Describe("ParseLanguages", func() {
|
||||||
@ -108,6 +114,111 @@ var _ = Describe("Configuration", func() {
|
|||||||
Entry("falls back to 'fts' for empty string", "", "fts"),
|
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",
|
DescribeTable("should load configuration from",
|
||||||
func(format string) {
|
func(format string) {
|
||||||
filename := filepath.Join("testdata", "cfg."+format)
|
filename := filepath.Join("testdata", "cfg."+format)
|
||||||
|
|||||||
@ -11,3 +11,11 @@ var ParseLanguages = parseLanguages
|
|||||||
var ValidateURL = validateURL
|
var ValidateURL = validateURL
|
||||||
|
|
||||||
var NormalizeSearchBackend = normalizeSearchBackend
|
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"
|
||||||
@ -70,7 +70,6 @@ const (
|
|||||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||||
PlaceholderAvatar = "logo-192x192.png"
|
PlaceholderAvatar = "logo-192x192.png"
|
||||||
UICoverArtSize = 300
|
|
||||||
DefaultUIVolume = 100
|
DefaultUIVolume = 100
|
||||||
DefaultUISearchDebounceMs = 200
|
DefaultUISearchDebounceMs = 200
|
||||||
|
|
||||||
@ -85,6 +84,10 @@ const (
|
|||||||
Zwsp = string('\u200b')
|
Zwsp = string('\u200b')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultUICoverArtSize = 300
|
||||||
|
)
|
||||||
|
|
||||||
// Prometheus options
|
// Prometheus options
|
||||||
const (
|
const (
|
||||||
PrometheusDefaultPath = "/metrics"
|
PrometheusDefaultPath = "/metrics"
|
||||||
@ -107,6 +110,7 @@ const (
|
|||||||
const (
|
const (
|
||||||
EntityArtist = "artist"
|
EntityArtist = "artist"
|
||||||
EntityPlaylist = "playlist"
|
EntityPlaylist = "playlist"
|
||||||
|
EntityRadio = "radio"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@ -124,6 +124,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
|||||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||||
case model.KindDiscArtwork:
|
case model.KindDiscArtwork:
|
||||||
artReader, err = newDiscArtworkReader(ctx, a, artID)
|
artReader, err = newDiscArtworkReader(ctx, a, artID)
|
||||||
|
case model.KindRadioArtwork:
|
||||||
|
artReader, err = newRadioArtworkReader(ctx, a, artID)
|
||||||
default:
|
default:
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@ -380,24 +380,24 @@ var _ = Describe("Artwork", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
When("Square is false", 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"
|
conf.Server.CoverArtPriority = "front.png"
|
||||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
img, format, err := image.Decode(r)
|
img, format, err := image.Decode(r)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
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().X).To(Equal(15))
|
||||||
Expect(img.Bounds().Size().Y).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"
|
conf.Server.CoverArtPriority = "cover.jpg"
|
||||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
img, format, err := image.Decode(r)
|
img, format, err := image.Decode(r)
|
||||||
Expect(format).To(Equal("webp"))
|
Expect(format).To(Equal("jpeg"))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||||
Expect(img.Bounds().Size().Y).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().X).To(Equal(size))
|
||||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||||
},
|
},
|
||||||
Entry("portrait png image", "png", "webp", false, 200),
|
Entry("portrait png image", "png", "png", false, 200),
|
||||||
Entry("landscape png image", "png", "webp", true, 200),
|
Entry("landscape png image", "png", "png", true, 200),
|
||||||
Entry("portrait jpg image", "jpg", "webp", false, 200),
|
Entry("portrait jpg image", "jpg", "png", false, 200),
|
||||||
Entry("landscape jpg image", "jpg", "webp", true, 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() {
|
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"
|
conf.Server.CoverArtPriority = "front.png"
|
||||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
img, format, err := image.Decode(r)
|
img, format, err := image.Decode(r)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
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().X).To(Equal(15))
|
||||||
Expect(img.Bounds().Size().Y).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))
|
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
|
var alCover model.Album
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.DevJpegCoverArt = true
|
conf.Server.EnableWebPEncoding = false
|
||||||
})
|
})
|
||||||
It("returns PNG for square mode", func() {
|
It("returns PNG for square mode", func() {
|
||||||
dirName := createImage("png", false, 200)
|
dirName := createImage("png", false, 200)
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"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
|
// 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
|
// 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 {
|
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||||
// If image cache is disabled, return a NOOP implementation
|
// If image cache is disabled, return a NOOP implementation
|
||||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||||
@ -42,6 +41,7 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
|||||||
cache: cache,
|
cache: cache,
|
||||||
buffer: make(map[model.ArtworkID]struct{}),
|
buffer: make(map[model.ArtworkID]struct{}),
|
||||||
wakeSignal: make(chan struct{}, 1),
|
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
|
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
|
||||||
@ -56,6 +56,7 @@ type cacheWarmer struct {
|
|||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
cache cache.FileCache
|
cache cache.FileCache
|
||||||
wakeSignal chan struct{}
|
wakeSignal chan struct{}
|
||||||
|
coverArtSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||||
@ -142,17 +143,15 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
|||||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
|
size := a.coverArtSize
|
||||||
|
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("caching id='%s': %w", id, err)
|
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||||
}
|
}
|
||||||
defer r.Close()
|
|
||||||
_, err = io.Copy(io.Discard, r)
|
_, err = io.Copy(io.Discard, r)
|
||||||
if err != nil {
|
r.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NoopCacheWarmer() CacheWarmer {
|
func NoopCacheWarmer() CacheWarmer {
|
||||||
return &noopCacheWarmer{}
|
return &noopCacheWarmer{}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -173,20 +174,42 @@ var _ = Describe("CacheWarmer", func() {
|
|||||||
return len(cw.buffer)
|
return len(cw.buffer)
|
||||||
}).Should(Equal(0))
|
}).Should(Equal(0))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("pre-caches UICoverArtSize", func() {
|
||||||
|
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||||
|
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||||
|
|
||||||
|
Eventually(func() []int {
|
||||||
|
return aw.getCachedSizes()
|
||||||
|
}).Should(ContainElements(conf.Server.UICoverArtSize))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
type mockArtwork struct {
|
type mockArtwork struct {
|
||||||
err error
|
err error
|
||||||
|
mu sync.Mutex
|
||||||
|
cachedSizes []int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
return nil, time.Time{}, m.err
|
return nil, time.Time{}, m.err
|
||||||
}
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.cachedSizes = append(m.cachedSizes, size)
|
||||||
|
m.mu.Unlock()
|
||||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockArtwork) getCachedSizes() []int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
result := make([]int, len(m.cachedSizes))
|
||||||
|
copy(result, m.cachedSizes)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
|||||||
func (a *albumArtworkReader) Key() string {
|
func (a *albumArtworkReader) Key() string {
|
||||||
hashInput := conf.Server.CoverArtPriority
|
hashInput := conf.Server.CoverArtPriority
|
||||||
if conf.Server.EnableExternalServices {
|
if conf.Server.EnableExternalServices {
|
||||||
hashInput += conf.Server.Agents
|
hashInput = conf.Server.Agents + hashInput
|
||||||
}
|
}
|
||||||
hash := md5.Sum([]byte(hashInput))
|
hash := md5.Sum([]byte(hashInput))
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
|
|||||||
@ -264,6 +264,6 @@ func fillCenter(src image.Image, dstW, dstH int) image.Image {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||||
xdraw.BiLinear.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil)
|
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil)
|
||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|||||||
40
core/artwork/reader_radio.go
Normal file
40
core/artwork/reader_radio.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type radioArtworkReader struct {
|
||||||
|
cacheKey
|
||||||
|
a *artwork
|
||||||
|
radio model.Radio
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) {
|
||||||
|
r, err := artwork.ds.Radio(ctx).Get(artID.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a := &radioArtworkReader{a: artwork, radio: *r}
|
||||||
|
a.cacheKey.artID = artID
|
||||||
|
a.cacheKey.lastUpdate = r.UpdatedAt
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *radioArtworkReader) LastUpdated() time.Time {
|
||||||
|
return a.lastUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||||
|
return selectImageReader(ctx, a.artID,
|
||||||
|
a.fromRadioUploadedImage(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc {
|
||||||
|
return fromLocalFile(a.radio.UploadedImagePath())
|
||||||
|
}
|
||||||
84
core/artwork/reader_radio_test.go
Normal file
84
core/artwork/reader_radio_test.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("radioArtworkReader", func() {
|
||||||
|
var (
|
||||||
|
tempDir string
|
||||||
|
reader *radioArtworkReader
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
tempDir = GinkgoT().TempDir()
|
||||||
|
conf.Server.DataFolder = tempDir
|
||||||
|
|
||||||
|
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||||
|
|
||||||
|
reader = &radioArtworkReader{}
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("fromRadioUploadedImage", func() {
|
||||||
|
When("radio has an uploaded image", func() {
|
||||||
|
It("returns the uploaded image", func() {
|
||||||
|
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||||
|
sf := reader.fromRadioUploadedImage()
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
Expect(path).To(Equal(imgPath))
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("radio has no uploaded image", func() {
|
||||||
|
It("returns nil reader (falls through)", func() {
|
||||||
|
reader.radio = model.Radio{ID: "rd-1"}
|
||||||
|
sf := reader.fromRadioUploadedImage()
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).To(BeNil())
|
||||||
|
Expect(path).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Reader", func() {
|
||||||
|
When("radio has an uploaded image", func() {
|
||||||
|
It("returns the image reader", func() {
|
||||||
|
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||||
|
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
|
||||||
|
r, _, err := reader.Reader(context.Background())
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("radio has no uploaded image", func() {
|
||||||
|
It("returns ErrUnavailable", func() {
|
||||||
|
reader.radio = model.Radio{ID: "rd-1"}
|
||||||
|
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
|
||||||
|
r, _, err := reader.Reader(context.Background())
|
||||||
|
Expect(err).To(MatchError(ErrUnavailable))
|
||||||
|
Expect(r).To(BeNil())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -19,6 +19,16 @@ import (
|
|||||||
xdraw "golang.org/x/image/draw"
|
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{
|
var bufPool = sync.Pool{
|
||||||
New: func() any {
|
New: func() any {
|
||||||
return new(bytes.Buffer)
|
return new(bytes.Buffer)
|
||||||
@ -98,8 +108,7 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader
|
|||||||
return nil, 0, fmt.Errorf("reading image data: %w", err)
|
return nil, 0, fmt.Errorf("reading image data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve animation for animated images (skip for square thumbnails)
|
// Preserve animation for animated images
|
||||||
if !a.square {
|
|
||||||
if isAnimatedGIF(data) {
|
if isAnimatedGIF(data) {
|
||||||
if a.a.ffmpeg.IsAvailable() {
|
if a.a.ffmpeg.IsAvailable() {
|
||||||
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
|
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
|
||||||
@ -113,13 +122,12 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader
|
|||||||
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
|
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
|
||||||
return bytes.NewReader(data), 0, nil
|
return bytes.NewReader(data), 0, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return resizeStaticImage(data, a.size, a.square)
|
return resizeStaticImage(data, a.size, a.square)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@ -155,19 +163,17 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
|
|||||||
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||||
dstRect = dst.Bounds()
|
dstRect = dst.Bounds()
|
||||||
}
|
}
|
||||||
xdraw.BiLinear.Scale(dst, dstRect, original, bounds, draw.Src, nil)
|
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
|
||||||
|
|
||||||
buf := bufPool.Get().(*bytes.Buffer)
|
buf := bufPool.Get().(*bytes.Buffer)
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
if conf.Server.DevJpegCoverArt {
|
if conf.Server.EnableWebPEncoding {
|
||||||
if square {
|
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
|
||||||
|
} else if format == "png" || square {
|
||||||
err = png.Encode(buf, dst)
|
err = png.Encode(buf, dst)
|
||||||
} else {
|
} else {
|
||||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bufPool.Put(buf)
|
bufPool.Put(buf)
|
||||||
return nil, originalSize, err
|
return nil, originalSize, err
|
||||||
|
|||||||
@ -54,17 +54,17 @@ var _ = Describe("resizeImage", func() {
|
|||||||
Expect(len(output)).To(BeNumerically(">", 0))
|
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
|
r.square = true
|
||||||
data := createAnimatedGIF(3)
|
data := createAnimatedGIF(3)
|
||||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||||
// Should fall through to static resize (not ffmpeg conversion)
|
Expect(err).ToNot(HaveOccurred())
|
||||||
// The minimal test GIF may or may not resize successfully,
|
Expect(result).ToNot(BeNil())
|
||||||
// but ffmpeg should NOT have been called for animated conversion
|
|
||||||
_ = result
|
// Should have been processed by ffmpeg (mock returns input data)
|
||||||
_ = err
|
output, err := io.ReadAll(result)
|
||||||
// Verify by checking the mock wasn't used for animated conversion:
|
Expect(err).ToNot(HaveOccurred())
|
||||||
// If ffmpeg was called, it would return mock data, not static resize result
|
Expect(output).To(Equal(data))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -81,13 +81,17 @@ var _ = Describe("resizeImage", func() {
|
|||||||
Expect(output).To(Equal(data))
|
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
|
r.square = true
|
||||||
data := createAnimatedWebPBytes()
|
data := createAnimatedWebPBytes()
|
||||||
// Should fall through to static resize, which will fail on fake WebP data
|
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||||
_, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
Expect(err).ToNot(HaveOccurred())
|
||||||
// Static decode will fail on our minimal test WebP bytes (not a real image)
|
Expect(result).ToNot(BeNil())
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
|
// 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))
|
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
|
r.square = true
|
||||||
data := createAPNGBytes()
|
data := createAPNGBytes()
|
||||||
// Should fall through to static resize
|
|
||||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||||
// Static PNG decode should succeed on our APNG (it's a valid PNG)
|
Expect(err).ToNot(HaveOccurred())
|
||||||
if err == nil {
|
|
||||||
Expect(result).ToNot(BeNil())
|
Expect(result).ToNot(BeNil())
|
||||||
}
|
|
||||||
|
// Should return original data unchanged
|
||||||
|
output, err := io.ReadAll(result)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(output).To(Equal(data))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -130,8 +130,23 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
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 {
|
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
|
||||||
|
|||||||
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
|
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()
|
imageUrl := artist.ArtistImageUrl()
|
||||||
if imageUrl == "" {
|
if imageUrl == "" {
|
||||||
// No cached URL — must fetch from external source synchronously
|
// 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()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
imageUrl = artist.ArtistImageUrl()
|
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 == "" {
|
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
|
package external_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
. "github.com/navidrome/navidrome/core/external"
|
. "github.com/navidrome/navidrome/core/external"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
@ -266,6 +269,68 @@ var _ = Describe("Provider - ArtistImage", func() {
|
|||||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
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() {
|
Context("Unicode handling in artist names", func() {
|
||||||
var artistWithEnDash *model.Artist
|
var artistWithEnDash *model.Artist
|
||||||
var expectedURL *url.URL
|
var expectedURL *url.URL
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package ffmpeg
|
package ffmpeg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@ -262,6 +263,7 @@ type ffCmd struct {
|
|||||||
args []string
|
args []string
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
input io.Reader // optional stdin source
|
input io.Reader // optional stdin source
|
||||||
|
stderr *bytes.Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *ffCmd) start(ctx context.Context) error {
|
func (j *ffCmd) start(ctx context.Context) error {
|
||||||
@ -270,10 +272,12 @@ func (j *ffCmd) start(ctx context.Context) error {
|
|||||||
if j.input != nil {
|
if j.input != nil {
|
||||||
cmd.Stdin = j.input
|
cmd.Stdin = j.input
|
||||||
}
|
}
|
||||||
|
j.stderr = &bytes.Buffer{}
|
||||||
|
stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096}
|
||||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter)
|
||||||
} else {
|
} else {
|
||||||
cmd.Stderr = io.Discard
|
cmd.Stderr = stderrWriter
|
||||||
}
|
}
|
||||||
j.cmd = cmd
|
j.cmd = cmd
|
||||||
|
|
||||||
@ -287,7 +291,11 @@ func (j *ffCmd) wait() {
|
|||||||
if err := j.cmd.Wait(); err != nil {
|
if err := j.cmd.Wait(); err != nil {
|
||||||
var exitErr *exec.ExitError
|
var exitErr *exec.ExitError
|
||||||
if errors.As(err, &exitErr) {
|
if errors.As(err, &exitErr) {
|
||||||
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())
|
||||||
|
if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" {
|
||||||
|
errMsg += ": " + stderrOutput
|
||||||
|
}
|
||||||
|
_ = j.out.CloseWithError(errors.New(errMsg))
|
||||||
} else {
|
} else {
|
||||||
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
||||||
}
|
}
|
||||||
@ -296,6 +304,26 @@ func (j *ffCmd) wait() {
|
|||||||
_ = j.out.Close()
|
_ = j.out.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// limitedWriter wraps a bytes.Buffer and stops writing once the limit is reached.
|
||||||
|
// Writes that would exceed the limit are silently discarded to prevent unbounded memory usage.
|
||||||
|
type limitedWriter struct {
|
||||||
|
buf *bytes.Buffer
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *limitedWriter) Write(p []byte) (int, error) {
|
||||||
|
n := len(p)
|
||||||
|
remaining := w.limit - w.buf.Len()
|
||||||
|
if remaining <= 0 {
|
||||||
|
return n, nil // Discard but report success to avoid breaking the writer
|
||||||
|
}
|
||||||
|
if len(p) > remaining {
|
||||||
|
p = p[:remaining]
|
||||||
|
}
|
||||||
|
w.buf.Write(p)
|
||||||
|
return n, nil // Always report full write to avoid ErrShortWrite from io.MultiWriter
|
||||||
|
}
|
||||||
|
|
||||||
// formatCodecMap maps target format to ffmpeg codec flag.
|
// formatCodecMap maps target format to ffmpeg codec flag.
|
||||||
var formatCodecMap = map[string]string{
|
var formatCodecMap = map[string]string{
|
||||||
"mp3": "libmp3lame",
|
"mp3": "libmp3lame",
|
||||||
|
|||||||
@ -584,9 +584,12 @@ var _ = Describe("ffmpeg", func() {
|
|||||||
// Cancel the context
|
// Cancel the context
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
// Next read should fail due to cancelled context
|
// 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)
|
_, err = stream.Read(buf)
|
||||||
Expect(err).To(HaveOccurred())
|
return err
|
||||||
|
}).WithTimeout(5 * time.Second).WithPolling(10 * time.Millisecond).Should(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should handle immediate context cancellation", func() {
|
It("should handle immediate context cancellation", func() {
|
||||||
@ -604,6 +607,46 @@ var _ = Describe("ffmpeg", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Context("stderr capture", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
Skip("stderr capture tests use /bin/sh, skipping on Windows")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should include stderr in error when process fails", func() {
|
||||||
|
ff := &ffmpeg{}
|
||||||
|
ctx := GinkgoT().Context()
|
||||||
|
|
||||||
|
// Directly call start() with a bash command that writes to stderr and fails
|
||||||
|
args := []string{"/bin/sh", "-c", "echo 'codec not found: libopus' >&2; exit 1"}
|
||||||
|
stream, err := ff.start(ctx, args)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
_, err = stream.Read(buf)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("codec not found: libopus"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should not include stderr in error when process succeeds", func() {
|
||||||
|
ff := &ffmpeg{}
|
||||||
|
ctx := GinkgoT().Context()
|
||||||
|
|
||||||
|
// Command that writes to stderr but exits successfully
|
||||||
|
args := []string{"/bin/sh", "-c", "echo 'warning: something' >&2; printf 'output'"}
|
||||||
|
stream, err := ff.start(ctx, args)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, err := stream.Read(buf)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(buf[:n])).To(Equal("output"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Context("with mock process behavior", func() {
|
Context("with mock process behavior", func() {
|
||||||
var longRunningCmd string
|
var longRunningCmd string
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|||||||
@ -193,6 +193,10 @@ var staticData = sync.OnceValue(func() insights.Data {
|
|||||||
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
|
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
|
||||||
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
|
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
|
||||||
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
|
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.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||||
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
||||||
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||||
|
|||||||
@ -63,6 +63,10 @@ type Data struct {
|
|||||||
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||||
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||||
EnablePrometheus bool `json:"enablePrometheus,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"`
|
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||||
|
|||||||
@ -31,8 +31,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
|||||||
filteredLines := make([]string, 0, len(lines))
|
filteredLines := make([]string, 0, len(lines))
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
line := strings.TrimSpace(line)
|
line := strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(line, "#PLAYLIST:") {
|
if after, ok := strings.CutPrefix(line, "#PLAYLIST:"); ok {
|
||||||
pls.Name = line[len("#PLAYLIST:"):]
|
pls.Name = after
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
|
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
|
||||||
|
|||||||
@ -5,8 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/utils/cache"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MediaStreamer interface {
|
type MediaStreamer interface {
|
||||||
@ -51,6 +53,9 @@ func (j *streamJob) Key() string {
|
|||||||
return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
|
return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewStream creates a Stream for the given MediaFile and Request. It handles both raw streaming (no transcoding)
|
||||||
|
// and transcoded streaming based on the requested format and bitrate. It also logs detailed information about
|
||||||
|
// the streaming request and whether the transcoding result was served from cache or not.
|
||||||
func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) {
|
func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) {
|
||||||
var format string
|
var format string
|
||||||
var bitRate int
|
var bitRate int
|
||||||
@ -133,14 +138,59 @@ func (s *Stream) EstimatedContentLength() int {
|
|||||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTestStream creates a Stream for testing purposes.
|
// Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent
|
||||||
func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream {
|
// (supporting range requests). For non-seekable streams it writes directly and logs any errors.
|
||||||
|
// Returns the number of bytes written and an error only when io.Copy fails with 0 bytes written
|
||||||
|
// (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response).
|
||||||
|
// Empty output (0 bytes, no error) is logged but not treated as an error.
|
||||||
|
func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) {
|
||||||
|
if s.Seekable() {
|
||||||
|
http.ServeContent(w, r, s.Name(), s.ModTime(), s)
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Accept-Ranges", "none")
|
||||||
|
w.Header().Set("Content-Type", s.ContentType())
|
||||||
|
|
||||||
|
if req.Params(r).BoolOr("estimateContentLength", false) {
|
||||||
|
length := strconv.Itoa(s.EstimatedContentLength())
|
||||||
|
log.Trace(ctx, "Estimated content-length", "contentLength", length)
|
||||||
|
w.Header().Set("Content-Length", length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
go func() { _, _ = io.Copy(io.Discard, s) }()
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
id := s.mf.ID
|
||||||
|
c, err := io.Copy(w, s)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Error sending transcoded file", "id", id, err)
|
||||||
|
if c == 0 {
|
||||||
|
w.Header().Del("Content-Length")
|
||||||
|
return 0, fmt.Errorf("sending transcoded file: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
if c == 0 {
|
||||||
|
log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+
|
||||||
|
"Check that ffmpeg supports the requested codec. Enable Trace logging for ffmpeg stderr details",
|
||||||
|
"id", id, "format", s.ContentType())
|
||||||
|
} else {
|
||||||
|
log.Trace(ctx, "Success sending transcoded file", "id", id, "size", c)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStream creates a non-seekable Stream from the given components.
|
||||||
|
func NewStream(mf *model.MediaFile, format string, bitRate int, r io.ReadCloser) *Stream {
|
||||||
return &Stream{
|
return &Stream{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
mf: mf,
|
mf: mf,
|
||||||
format: format,
|
format: format,
|
||||||
bitRate: bitRate,
|
bitRate: bitRate,
|
||||||
ReadCloser: io.NopCloser(strings.NewReader("")),
|
ReadCloser: r,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
db/migrations/20260318182414_add_radio_uploaded_image.go
Normal file
22
db/migrations/20260318182414_add_radio_uploaded_image.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
goose.AddMigrationContext(upAddRadioUploadedImage, downAddRadioUploadedImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `ALTER TABLE radio ADD COLUMN uploaded_image VARCHAR(255) NOT NULL DEFAULT ''`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func downAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
// This code is executed when the migration is rolled back.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
26
go.mod
26
go.mod
@ -36,12 +36,12 @@ require (
|
|||||||
github.com/kardianos/service v1.2.4
|
github.com/kardianos/service v1.2.4
|
||||||
github.com/kr/pretty v0.3.1
|
github.com/kr/pretty v0.3.1
|
||||||
github.com/lestrrat-go/jwx/v3 v3.0.13
|
github.com/lestrrat-go/jwx/v3 v3.0.13
|
||||||
github.com/mattn/go-sqlite3 v1.14.34
|
github.com/mattn/go-sqlite3 v1.14.38
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/mileusna/useragent v1.3.5
|
github.com/mileusna/useragent v1.3.5
|
||||||
github.com/onsi/ginkgo/v2 v2.28.1
|
github.com/onsi/ginkgo/v2 v2.28.1
|
||||||
github.com/onsi/gomega v1.39.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/pocketbase/dbx v1.12.0
|
||||||
github.com/pressly/goose/v3 v3.27.0
|
github.com/pressly/goose/v3 v3.27.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
@ -58,12 +58,12 @@ require (
|
|||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||||
go.senan.xyz/taglib v0.11.1
|
go.senan.xyz/taglib v0.11.1
|
||||||
go.uber.org/goleak v1.3.0
|
go.uber.org/goleak v1.3.0
|
||||||
golang.org/x/image v0.36.0
|
golang.org/x/image v0.38.0
|
||||||
golang.org/x/net v0.51.0
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.20.0
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sys v0.42.0
|
||||||
golang.org/x/term v0.40.0
|
golang.org/x/term v0.41.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.35.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@ -80,13 +80,13 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
|
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
|
||||||
github.com/ebitengine/purego v0.8.3 // indirect
|
github.com/ebitengine/purego v0.10.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/gobwas/glob v0.2.3 // indirect
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||||
@ -104,7 +104,7 @@ require (
|
|||||||
github.com/lestrrat-go/dsig v1.0.0 // indirect
|
github.com/lestrrat-go/dsig v1.0.0 // indirect
|
||||||
github.com/lestrrat-go/dsig-secp256k1 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/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/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||||
github.com/maruel/natural v1.3.0 // indirect
|
github.com/maruel/natural v1.3.0 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
@ -134,10 +134,10 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.1 // indirect
|
gopkg.in/ini.v1 v1.67.1 // indirect
|
||||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||||
|
|||||||
52
go.sum
52
go.sum
@ -56,8 +56,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
|
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
|
||||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
|
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
|
||||||
github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc=
|
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||||
github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
|
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
|
||||||
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
|
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
|
||||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||||
@ -96,8 +96,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
|||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
|
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
|
||||||
@ -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/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 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
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.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
|
||||||
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
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 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
|
||||||
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
|
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=
|
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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
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/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 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
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.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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/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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
@ -319,19 +319,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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 h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@ -343,8 +343,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@ -372,8 +372,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
|
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
|
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@ -382,8 +382,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@ -394,8 +394,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@ -405,8 +405,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@ -23,6 +23,7 @@ var (
|
|||||||
KindAlbumArtwork = Kind{"al", "album"}
|
KindAlbumArtwork = Kind{"al", "album"}
|
||||||
KindPlaylistArtwork = Kind{"pl", "playlist"}
|
KindPlaylistArtwork = Kind{"pl", "playlist"}
|
||||||
KindDiscArtwork = Kind{"dc", "disc"}
|
KindDiscArtwork = Kind{"dc", "disc"}
|
||||||
|
KindRadioArtwork = Kind{"ra", "radio"}
|
||||||
)
|
)
|
||||||
|
|
||||||
var artworkKindMap = map[string]Kind{
|
var artworkKindMap = map[string]Kind{
|
||||||
@ -31,6 +32,7 @@ var artworkKindMap = map[string]Kind{
|
|||||||
KindAlbumArtwork.prefix: KindAlbumArtwork,
|
KindAlbumArtwork.prefix: KindAlbumArtwork,
|
||||||
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
|
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
|
||||||
KindDiscArtwork.prefix: KindDiscArtwork,
|
KindDiscArtwork.prefix: KindDiscArtwork,
|
||||||
|
KindRadioArtwork.prefix: KindRadioArtwork,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtworkID struct {
|
type ArtworkID struct {
|
||||||
@ -139,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID {
|
|||||||
ID: ar.ID,
|
ID: ar.ID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func artworkIDFromRadio(r Radio) ArtworkID {
|
||||||
|
return ArtworkID{
|
||||||
|
Kind: KindRadioArtwork,
|
||||||
|
ID: r.ID,
|
||||||
|
LastUpdate: r.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ var fieldMap = map[string]*mappedField{
|
|||||||
"releasedate": {field: "media_file.release_date"},
|
"releasedate": {field: "media_file.release_date"},
|
||||||
"size": {field: "media_file.size"},
|
"size": {field: "media_file.size"},
|
||||||
"compilation": {field: "media_file.compilation"},
|
"compilation": {field: "media_file.compilation"},
|
||||||
|
"missing": {field: "media_file.missing"},
|
||||||
"explicitstatus": {field: "media_file.explicit_status"},
|
"explicitstatus": {field: "media_file.explicit_status"},
|
||||||
"dateadded": {field: "media_file.created_at"},
|
"dateadded": {field: "media_file.created_at"},
|
||||||
"datemodified": {field: "media_file.updated_at"},
|
"datemodified": {field: "media_file.updated_at"},
|
||||||
@ -49,9 +50,11 @@ var fieldMap = map[string]*mappedField{
|
|||||||
"catalognumber": {field: "media_file.catalog_num"},
|
"catalognumber": {field: "media_file.catalog_num"},
|
||||||
"filepath": {field: "media_file.path"},
|
"filepath": {field: "media_file.path"},
|
||||||
"filetype": {field: "media_file.suffix"},
|
"filetype": {field: "media_file.suffix"},
|
||||||
|
"codec": {field: "media_file.codec"},
|
||||||
"duration": {field: "media_file.duration"},
|
"duration": {field: "media_file.duration"},
|
||||||
"bitrate": {field: "media_file.bit_rate"},
|
"bitrate": {field: "media_file.bit_rate"},
|
||||||
"bitdepth": {field: "media_file.bit_depth"},
|
"bitdepth": {field: "media_file.bit_depth"},
|
||||||
|
"samplerate": {field: "media_file.sample_rate"},
|
||||||
"bpm": {field: "media_file.bpm"},
|
"bpm": {field: "media_file.bpm"},
|
||||||
"channels": {field: "media_file.channels"},
|
"channels": {field: "media_file.channels"},
|
||||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||||
|
|||||||
@ -22,5 +22,9 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return mf, nil
|
return mf, nil
|
||||||
}
|
}
|
||||||
|
r, err := ds.Radio(ctx).Get(id)
|
||||||
|
if err == nil {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,7 +65,7 @@ var _ = Describe("getPID", func() {
|
|||||||
Context("calculated attributes", func() {
|
Context("calculated attributes", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,version,releasedate"
|
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
||||||
})
|
})
|
||||||
When("field is title", func() {
|
When("field is title", func() {
|
||||||
It("should return the pid", func() {
|
It("should return the pid", func() {
|
||||||
@ -90,11 +90,11 @@ var _ = Describe("getPID", func() {
|
|||||||
md.tags = map[model.TagName][]string{
|
md.tags = map[model.TagName][]string{
|
||||||
"title": {"title"},
|
"title": {"title"},
|
||||||
"album": {"album name"},
|
"album": {"album name"},
|
||||||
"version": {"version"},
|
"albumversion": {"deluxe edition"},
|
||||||
"releasedate": {"2021-01-01"},
|
"releasedate": {"2021-01-01"},
|
||||||
}
|
}
|
||||||
mf.AlbumArtist = "Album Artist"
|
mf.AlbumArtist = "Album Artist"
|
||||||
Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))"))
|
Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\deluxe edition\\2021-01-01))"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
When("field is albumartistid", func() {
|
When("field is albumartistid", func() {
|
||||||
|
|||||||
@ -1,16 +1,29 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
)
|
||||||
|
|
||||||
type Radio struct {
|
type Radio struct {
|
||||||
ID string `structs:"id" json:"id"`
|
ID string `structs:"id" json:"id"`
|
||||||
StreamUrl string `structs:"stream_url" json:"streamUrl"`
|
StreamUrl string `structs:"stream_url" json:"streamUrl"`
|
||||||
Name string `structs:"name" json:"name"`
|
Name string `structs:"name" json:"name"`
|
||||||
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
|
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
|
||||||
|
UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
|
||||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Radio) CoverArtID() ArtworkID {
|
||||||
|
return artworkIDFromRadio(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Radio) UploadedImagePath() string {
|
||||||
|
return UploadedImagePath(consts.EntityRadio, r.UploadedImage)
|
||||||
|
}
|
||||||
|
|
||||||
type Radios []Radio
|
type Radios []Radio
|
||||||
|
|
||||||
type RadioRepository interface {
|
type RadioRepository interface {
|
||||||
@ -19,5 +32,5 @@ type RadioRepository interface {
|
|||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
Get(id string) (*Radio, error)
|
Get(id string) (*Radio, error)
|
||||||
GetAll(options ...QueryOptions) (Radios, error)
|
GetAll(options ...QueryOptions) (Radios, error)
|
||||||
Put(u *Radio) error
|
Put(u *Radio, colsToUpdate ...string) error
|
||||||
}
|
}
|
||||||
|
|||||||
42
model/radio_test.go
Normal file
42
model/radio_test.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package model_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Radio", func() {
|
||||||
|
Describe("CoverArtID", func() {
|
||||||
|
It("returns a radio artwork ID", func() {
|
||||||
|
now := time.Now()
|
||||||
|
r := model.Radio{ID: "rd-1", UpdatedAt: now}
|
||||||
|
artID := r.CoverArtID()
|
||||||
|
Expect(artID.Kind).To(Equal(model.KindRadioArtwork))
|
||||||
|
Expect(artID.ID).To(Equal("rd-1"))
|
||||||
|
Expect(artID.LastUpdate).To(Equal(now))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("UploadedImagePath", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.DataFolder = "/data"
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns empty string when no image uploaded", func() {
|
||||||
|
r := model.Radio{ID: "rd-1"}
|
||||||
|
Expect(r.UploadedImagePath()).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns full path when image is set", func() {
|
||||||
|
r := model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||||
|
Expect(r.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "radio", "rd-1_test.jpg")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -4,7 +4,9 @@ import (
|
|||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -12,6 +14,7 @@ import (
|
|||||||
. "github.com/Masterminds/squirrel"
|
. "github.com/Masterminds/squirrel"
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
@ -315,7 +318,19 @@ func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *artistRepository) purgeEmpty() error {
|
func (r *artistRepository) purgeEmpty() error {
|
||||||
del := Delete(r.tableName).Where("id not in (select artist_id from album_artists)")
|
orphanFilter := "id not in (select artist_id from album_artists)"
|
||||||
|
|
||||||
|
// Collect uploaded image filenames before deleting
|
||||||
|
sel := Select("uploaded_image").From(r.tableName).
|
||||||
|
Where(orphanFilter).
|
||||||
|
Where("uploaded_image != ''")
|
||||||
|
var imageFiles []string
|
||||||
|
if err := r.queryAllSlice(sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
|
return fmt.Errorf("collecting artist images for cleanup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete orphan artists
|
||||||
|
del := Delete(r.tableName).Where(orphanFilter)
|
||||||
c, err := r.executeSQL(del)
|
c, err := r.executeSQL(del)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("purging empty artists: %w", err)
|
return fmt.Errorf("purging empty artists: %w", err)
|
||||||
@ -323,6 +338,19 @@ func (r *artistRepository) purgeEmpty() error {
|
|||||||
if c > 0 {
|
if c > 0 {
|
||||||
log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c)
|
log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(imageFiles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort cleanup of uploaded image files
|
||||||
|
log.Debug(r.ctx, "Cleaning up artist images", "totalImages", len(imageFiles))
|
||||||
|
for _, filename := range imageFiles {
|
||||||
|
path := model.UploadedImagePath(consts.EntityArtist, filename)
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Warn(r.ctx, "Failed to remove artist image during GC", "path", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,14 @@ package persistence
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
@ -829,6 +832,89 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("purgeEmpty", func() {
|
||||||
|
var repo *artistRepository
|
||||||
|
var tmpDir string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
tmpDir = GinkgoT().TempDir()
|
||||||
|
conf.Server.DataFolder = tmpDir
|
||||||
|
|
||||||
|
ctx := request.WithUser(GinkgoT().Context(), adminUser)
|
||||||
|
repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper to create an artist image file on disk and return its path
|
||||||
|
createImageFile := func(filename string) string {
|
||||||
|
dir := filepath.Join(tmpDir, consts.ArtworkFolder, consts.EntityArtist)
|
||||||
|
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||||
|
path := filepath.Join(dir, filename)
|
||||||
|
Expect(os.WriteFile(path, []byte("fake image data"), 0600)).To(Succeed())
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
It("removes uploaded image files for purged artists", func() {
|
||||||
|
// Create an orphan artist (not in album_artists) with an uploaded image
|
||||||
|
orphanArtist := model.Artist{ID: "orphan-with-image", Name: "Orphan Artist", UploadedImage: "orphan-with-image_Orphan_Artist.jpg"}
|
||||||
|
Expect(repo.Put(&orphanArtist)).To(Succeed())
|
||||||
|
imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg")
|
||||||
|
|
||||||
|
Expect(repo.purgeEmpty()).To(Succeed())
|
||||||
|
|
||||||
|
// Artist should be gone from DB
|
||||||
|
exists, err := repo.Exists("orphan-with-image")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(exists).To(BeFalse())
|
||||||
|
|
||||||
|
// Image file should be removed from disk
|
||||||
|
_, err = os.Stat(imgPath)
|
||||||
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles missing image files gracefully", func() {
|
||||||
|
// Artist has UploadedImage set but no actual file on disk
|
||||||
|
orphanArtist := model.Artist{ID: "orphan-no-file", Name: "Ghost Image", UploadedImage: "orphan-no-file_Ghost_Image.jpg"}
|
||||||
|
Expect(repo.Put(&orphanArtist)).To(Succeed())
|
||||||
|
|
||||||
|
Expect(repo.purgeEmpty()).To(Succeed())
|
||||||
|
|
||||||
|
// Artist should be gone from DB
|
||||||
|
exists, err := repo.Exists("orphan-no-file")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(exists).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not delete images for artists that are kept", func() {
|
||||||
|
// Create an artist with an uploaded image AND an album_artists entry so it won't be purged
|
||||||
|
keptArtist := model.Artist{ID: "kept-artist", Name: "Kept Artist", UploadedImage: "kept-artist_Kept_Artist.jpg"}
|
||||||
|
Expect(repo.Put(&keptArtist)).To(Succeed())
|
||||||
|
imgPath := createImageFile("kept-artist_Kept_Artist.jpg")
|
||||||
|
|
||||||
|
// Insert an album_artists record to keep this artist from being purged
|
||||||
|
_, err := repo.executeSQL(squirrel.Insert("album_artists").
|
||||||
|
SetMap(map[string]any{"album_id": "101", "artist_id": "kept-artist", "role": "artist", "sub_role": ""}))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
DeferCleanup(func() {
|
||||||
|
_, _ = repo.executeSQL(squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"}))
|
||||||
|
_ = repo.delete(squirrel.Eq{"id": "kept-artist"})
|
||||||
|
})
|
||||||
|
|
||||||
|
Expect(repo.purgeEmpty()).To(Succeed())
|
||||||
|
|
||||||
|
// Artist should still exist (check directly, bypassing library filter)
|
||||||
|
var ids []string
|
||||||
|
err = repo.queryAllSlice(squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(ids).To(HaveLen(1))
|
||||||
|
|
||||||
|
// Image file should still be on disk
|
||||||
|
_, err = os.Stat(imgPath)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Helper function to create an artist with proper library association.
|
// Helper function to create an artist with proper library association.
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
@ -211,6 +212,27 @@ var _ = BeforeSuite(func() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate album_artists based on the AlbumArtistID relationships in testAlbums
|
||||||
|
artistIDs := map[string]bool{}
|
||||||
|
for _, a := range testArtists {
|
||||||
|
artistIDs[a.ID] = true
|
||||||
|
}
|
||||||
|
for i := range testAlbums {
|
||||||
|
a := testAlbums[i]
|
||||||
|
if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err := alr.executeSQL(squirrel.Insert("album_artists").SetMap(map[string]any{
|
||||||
|
"album_id": a.ID,
|
||||||
|
"artist_id": a.AlbumArtistID,
|
||||||
|
"role": "artist",
|
||||||
|
"sub_role": "",
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mr := NewMediaFileRepository(ctx, conn)
|
mr := NewMediaFileRepository(ctx, conn)
|
||||||
for i := range testSongs {
|
for i := range testSongs {
|
||||||
err := mr.Put(&testSongs[i])
|
err := mr.Put(&testSongs[i])
|
||||||
|
|||||||
@ -58,34 +58,20 @@ func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, e
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *radioRepository) Put(radio *model.Radio) error {
|
func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error {
|
||||||
if !r.isPermitted() {
|
if !r.isPermitted() {
|
||||||
return rest.ErrPermissionDenied
|
return rest.ErrPermissionDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
var values map[string]any
|
|
||||||
|
|
||||||
radio.UpdatedAt = time.Now()
|
radio.UpdatedAt = time.Now()
|
||||||
|
|
||||||
if radio.ID == "" {
|
if radio.ID == "" {
|
||||||
radio.CreatedAt = time.Now()
|
radio.CreatedAt = time.Now()
|
||||||
radio.ID = id.NewRandom()
|
radio.ID = id.NewRandom()
|
||||||
values, _ = toSQLArgs(*radio)
|
|
||||||
} else {
|
|
||||||
values, _ = toSQLArgs(*radio)
|
|
||||||
update := Update(r.tableName).Where(Eq{"id": radio.ID}).SetMap(values)
|
|
||||||
count, err := r.executeSQL(update)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
if len(colsToUpdate) > 0 {
|
||||||
|
colsToUpdate = append(colsToUpdate, "UpdatedAt")
|
||||||
}
|
}
|
||||||
|
_, err := r.put(radio.ID, radio, colsToUpdate...)
|
||||||
values["created_at"] = time.Now()
|
|
||||||
insert := Insert(r.tableName).SetMap(values)
|
|
||||||
_, err := r.executeSQL(insert)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -178,7 +178,9 @@ func buildFTS5Query(userInput string) string {
|
|||||||
tokens[i] = t + "*"
|
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 {
|
for i, phrase := range phrases {
|
||||||
placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i)
|
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 empty input", "", ""),
|
||||||
Entry("returns empty string for whitespace-only input", " ", ""),
|
Entry("returns empty string for whitespace-only input", " ", ""),
|
||||||
Entry("appends * to a single word for prefix matching", "beatles", "beatles*"),
|
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("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`),
|
||||||
Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"),
|
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 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* col* val*"),
|
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" abbey*`),
|
Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND abbey*`),
|
||||||
Entry("handles prefix with multiple words", "beat* abbey", "beat* abbey*"),
|
Entry("handles prefix with multiple words", "beat* abbey", "beat* AND abbey*"),
|
||||||
Entry("collapses multiple spaces", "abbey road", "abbey* road*"),
|
Entry("collapses multiple spaces", "abbey road", "abbey* AND road*"),
|
||||||
Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"),
|
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 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 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 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 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 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("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("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`),
|
||||||
Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* roll* vol* 2*"),
|
Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`),
|
||||||
Entry("preserves unicode characters with diacritics", "Björk début", "Björk* début*"),
|
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 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 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("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 letter surrounded by words", "I am fine", "I* AND am* AND fine*"),
|
||||||
Entry("does not collapse single standalone letter", "A test", "A* test*"),
|
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 phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`),
|
||||||
Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`),
|
Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`),
|
||||||
Entry("returns empty string for punctuation-only input", "!!!!!!!", ""),
|
Entry("returns empty string for punctuation-only input", "!!!!!!!", ""),
|
||||||
|
|||||||
@ -7,6 +7,7 @@ type HTTPRequest struct {
|
|||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Headers map[string]string `json:"headers,omitempty"`
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
NoFollowRedirects bool `json:"noFollowRedirects,omitempty"`
|
||||||
Body []byte `json:"body,omitempty"`
|
Body []byte `json:"body,omitempty"`
|
||||||
TimeoutMs int32 `json:"timeoutMs,omitempty"`
|
TimeoutMs int32 `json:"timeoutMs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,12 @@ const (
|
|||||||
httpClientMaxResponseBodyLen = 10 * 1024 * 1024 // 10 MB
|
httpClientMaxResponseBodyLen = 10 * 1024 * 1024 // 10 MB
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// contextKey is used for per-request redirect control via context.
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
// noFollowRedirectsKey signals the CheckRedirect callback to stop following redirects.
|
||||||
|
var noFollowRedirectsKey = contextKey{}
|
||||||
|
|
||||||
// httpServiceImpl implements host.HTTPService.
|
// httpServiceImpl implements host.HTTPService.
|
||||||
type httpServiceImpl struct {
|
type httpServiceImpl struct {
|
||||||
pluginName string
|
pluginName string
|
||||||
@ -44,6 +50,9 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI
|
|||||||
// Timeout is set per-request via context deadline, not here.
|
// Timeout is set per-request via context deadline, not here.
|
||||||
// CheckRedirect validates hosts and enforces redirect limits.
|
// CheckRedirect validates hosts and enforces redirect limits.
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if req.Context().Value(noFollowRedirectsKey) != nil {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
if len(via) >= httpClientMaxRedirects {
|
if len(via) >= httpClientMaxRedirects {
|
||||||
log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via))
|
log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via))
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
@ -80,6 +89,11 @@ func (s *httpServiceImpl) Send(ctx context.Context, request host.HTTPRequest) (*
|
|||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Signal CheckRedirect to not follow redirects for this request
|
||||||
|
if request.NoFollowRedirects {
|
||||||
|
ctx = context.WithValue(ctx, noFollowRedirectsKey, true)
|
||||||
|
}
|
||||||
|
|
||||||
// Build request body
|
// Build request body
|
||||||
method := strings.ToUpper(request.Method)
|
method := strings.ToUpper(request.Method)
|
||||||
var body io.Reader
|
var body io.Reader
|
||||||
|
|||||||
@ -311,6 +311,26 @@ var _ = Describe("httpServiceImpl", func() {
|
|||||||
Expect(err.Error()).To(ContainSubstring("context canceled"))
|
Expect(err.Error()).To(ContainSubstring("context canceled"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("should not follow redirects when NoFollowRedirects is true", func() {
|
||||||
|
dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("final"))
|
||||||
|
}))
|
||||||
|
defer dest.Close()
|
||||||
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, dest.URL, http.StatusFound)
|
||||||
|
}))
|
||||||
|
resp, err := svc.Send(context.Background(), host.HTTPRequest{
|
||||||
|
Method: "GET",
|
||||||
|
URL: ts.URL,
|
||||||
|
TimeoutMs: 1000,
|
||||||
|
NoFollowRedirects: true,
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp.StatusCode).To(Equal(int32(302)))
|
||||||
|
Expect(resp.Headers["Location"]).To(Equal(dest.URL))
|
||||||
|
Expect(string(resp.Body)).ToNot(Equal("final"))
|
||||||
|
})
|
||||||
|
|
||||||
It("should send request headers", func() {
|
It("should send request headers", func() {
|
||||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(r.Header.Get("X-Custom")))
|
_, _ = w.Write([]byte(r.Header.Get("X-Custom")))
|
||||||
|
|||||||
@ -544,7 +544,7 @@ var _ = Describe("LibraryService Integration", Ordered, func() {
|
|||||||
// Note: This test is slightly flaky due to a potential race condition in wazero's
|
// Note: This test is slightly flaky due to a potential race condition in wazero's
|
||||||
// WASI filesystem mounting. The test passes ~85% of the time. Using FlakeAttempts
|
// WASI filesystem mounting. The test passes ~85% of the time. Using FlakeAttempts
|
||||||
// to automatically retry on failure.
|
// to automatically retry on failure.
|
||||||
It("should read file from mounted library directory", FlakeAttempts(3), func() {
|
It("should read file from mounted library directory", FlakeAttempts(5), func() {
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
|
|
||||||
output, err := callTestLibrary(ctx, testLibraryInput{
|
output, err := callTestLibrary(ctx, testLibraryInput{
|
||||||
@ -557,7 +557,7 @@ var _ = Describe("LibraryService Integration", Ordered, func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Note: Uses FlakeAttempts for the same reason as the read_file test above
|
// Note: Uses FlakeAttempts for the same reason as the read_file test above
|
||||||
It("should list files in mounted library directory", FlakeAttempts(3), func() {
|
It("should list files in mounted library directory", FlakeAttempts(5), func() {
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
|
|
||||||
output, err := callTestLibrary(ctx, testLibraryInput{
|
output, err := callTestLibrary(ctx, testLibraryInput{
|
||||||
|
|||||||
@ -20,6 +20,7 @@ type HTTPRequest struct {
|
|||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
|
NoFollowRedirects bool `json:"noFollowRedirects"`
|
||||||
Body []byte `json:"body"`
|
Body []byte `json:"body"`
|
||||||
TimeoutMs int32 `json:"timeoutMs"`
|
TimeoutMs int32 `json:"timeoutMs"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ type HTTPRequest struct {
|
|||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
|
NoFollowRedirects bool `json:"noFollowRedirects"`
|
||||||
Body []byte `json:"body"`
|
Body []byte `json:"body"`
|
||||||
TimeoutMs int32 `json:"timeoutMs"`
|
TimeoutMs int32 `json:"timeoutMs"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,8 @@ pub struct HTTPRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub headers: std::collections::HashMap<String, String>,
|
pub headers: std::collections::HashMap<String, String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub no_follow_redirects: bool,
|
||||||
|
#[serde(default)]
|
||||||
#[serde(with = "base64_bytes")]
|
#[serde(with = "base64_bytes")]
|
||||||
pub body: Vec<u8>,
|
pub body: Vec<u8>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
"missing": "Manglende",
|
"missing": "Manglende",
|
||||||
"libraryName": "Bibliotek",
|
"libraryName": "Bibliotek",
|
||||||
"composer": "Komponist",
|
"composer": "Komponist",
|
||||||
"disc": ""
|
"disc": "Disk %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Afspil senere",
|
"addToQueue": "Afspil senere",
|
||||||
@ -355,7 +355,7 @@
|
|||||||
"selectedUsers": "Valgte brugere",
|
"selectedUsers": "Valgte brugere",
|
||||||
"allLibraries": "Tillad alle biblioteker",
|
"allLibraries": "Tillad alle biblioteker",
|
||||||
"selectedLibraries": "Valgte biblioteker",
|
"selectedLibraries": "Valgte biblioteker",
|
||||||
"allowWriteAccess": ""
|
"allowWriteAccess": "Tillad skriveadgang"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@ -401,7 +401,7 @@
|
|||||||
"requiredHosts": "Påkrævede hosts",
|
"requiredHosts": "Påkrævede hosts",
|
||||||
"configValidationError": "Konfigurationsvalidering mislykkedes:",
|
"configValidationError": "Konfigurationsvalidering mislykkedes:",
|
||||||
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.",
|
"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": {
|
"placeholders": {
|
||||||
"configKey": "nøgle",
|
"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.",
|
"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",
|
"noSimilarSongsFound": "Ingen lignende sange fundet",
|
||||||
"noTopSongsFound": "Ingen topsange 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": {
|
"menu": {
|
||||||
"library": "Bibliotek",
|
"library": "Bibliotek",
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
"size": "Dateigröße",
|
"size": "Dateigröße",
|
||||||
"updatedAt": "Hochgeladen am",
|
"updatedAt": "Hochgeladen am",
|
||||||
"bitRate": "Bitrate",
|
"bitRate": "Bitrate",
|
||||||
"discSubtitle": "CD Untertitel",
|
"discSubtitle": "Disc Untertitel",
|
||||||
"starred": "Favorit",
|
"starred": "Favorit",
|
||||||
"comment": "Kommentar",
|
"comment": "Kommentar",
|
||||||
"rating": "Bewertung",
|
"rating": "Bewertung",
|
||||||
@ -38,7 +38,7 @@
|
|||||||
"missing": "Fehlend",
|
"missing": "Fehlend",
|
||||||
"libraryName": "Bibliothek",
|
"libraryName": "Bibliothek",
|
||||||
"composer": "Komponist",
|
"composer": "Komponist",
|
||||||
"disc": ""
|
"disc": "Disc %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Später abspielen",
|
"addToQueue": "Später abspielen",
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
"missing": "Απών",
|
"missing": "Απών",
|
||||||
"libraryName": "Βιβλιοθήκη",
|
"libraryName": "Βιβλιοθήκη",
|
||||||
"composer": "Συνθέτης",
|
"composer": "Συνθέτης",
|
||||||
"disc": ""
|
"disc": "Δίσκος %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Αναπαραγωγη Μετα",
|
"addToQueue": "Αναπαραγωγη Μετα",
|
||||||
@ -355,7 +355,7 @@
|
|||||||
"selectedUsers": "Επιλογή χρηστών",
|
"selectedUsers": "Επιλογή χρηστών",
|
||||||
"allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες",
|
"allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες",
|
||||||
"selectedLibraries": "Επιλεγμένες βιβλιοθήκες",
|
"selectedLibraries": "Επιλεγμένες βιβλιοθήκες",
|
||||||
"allowWriteAccess": ""
|
"allowWriteAccess": "Επιτρέψτε την πρόσβαση εγγραφής"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Κατάσταση",
|
"status": "Κατάσταση",
|
||||||
@ -401,7 +401,7 @@
|
|||||||
"requiredHosts": "Απαιτούμενοι hosts",
|
"requiredHosts": "Απαιτούμενοι hosts",
|
||||||
"configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:",
|
"configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:",
|
||||||
"schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.",
|
"schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.",
|
||||||
"allowWriteAccessHelp": ""
|
"allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση."
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "κλειδί",
|
"configKey": "κλειδί",
|
||||||
@ -591,7 +591,13 @@
|
|||||||
"remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.",
|
"remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.",
|
||||||
"noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια",
|
"noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια",
|
||||||
"noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια",
|
"noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια",
|
||||||
"startingInstantMix": "Φόρτωση Άμεσης Μίξης..."
|
"startingInstantMix": "Φόρτωση Άμεσης Μίξης...",
|
||||||
|
"uploadCover": "Μεταφόρτωση εξωφύλλου",
|
||||||
|
"removeCover": "Αφαίρεση καλύμματος",
|
||||||
|
"coverUploaded": "Το εξώφυλλο ενημερώθηκε",
|
||||||
|
"coverRemoved": "Το εξώφυλλο αφαιρέθηκε",
|
||||||
|
"coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου",
|
||||||
|
"coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"library": "Βιβλιοθήκη",
|
"library": "Βιβλιοθήκη",
|
||||||
|
|||||||
@ -36,7 +36,9 @@
|
|||||||
"bitDepth": "Bitprofundo",
|
"bitDepth": "Bitprofundo",
|
||||||
"sampleRate": "Elprena rapido",
|
"sampleRate": "Elprena rapido",
|
||||||
"missing": "Mankaj",
|
"missing": "Mankaj",
|
||||||
"libraryName": "Biblioteko"
|
"libraryName": "Biblioteko",
|
||||||
|
"composer": "",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Ludi Poste",
|
"addToQueue": "Ludi Poste",
|
||||||
@ -46,7 +48,8 @@
|
|||||||
"download": "Elŝuti",
|
"download": "Elŝuti",
|
||||||
"playNext": "Ludu Poste",
|
"playNext": "Ludu Poste",
|
||||||
"info": "Akiri Informon",
|
"info": "Akiri Informon",
|
||||||
"showInPlaylist": "Montri en Ludlisto"
|
"showInPlaylist": "Montri en Ludlisto",
|
||||||
|
"instantMix": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"album": {
|
"album": {
|
||||||
@ -328,6 +331,82 @@
|
|||||||
"scanInProgress": "Skano progresas...",
|
"scanInProgress": "Skano progresas...",
|
||||||
"noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto"
|
"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": {
|
"ra": {
|
||||||
@ -511,7 +590,14 @@
|
|||||||
"remove_all_missing_title": "Forigi ĉiujn mankajn dosierojn",
|
"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.",
|
"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",
|
"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": {
|
"menu": {
|
||||||
"library": "Biblioteko",
|
"library": "Biblioteko",
|
||||||
@ -597,7 +683,8 @@
|
|||||||
"exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
|
"exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
|
||||||
"exportFailed": "Malsukcesis kopii agordojn",
|
"exportFailed": "Malsukcesis kopii agordojn",
|
||||||
"devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)",
|
"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": {
|
"activity": {
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
"missing": "Faltante",
|
"missing": "Faltante",
|
||||||
"libraryName": "Biblioteca",
|
"libraryName": "Biblioteca",
|
||||||
"composer": "Compositor",
|
"composer": "Compositor",
|
||||||
"disc": ""
|
"disc": "Disco %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Reproducir después",
|
"addToQueue": "Reproducir después",
|
||||||
@ -355,7 +355,7 @@
|
|||||||
"selectedUsers": "Usuarios seleccionados",
|
"selectedUsers": "Usuarios seleccionados",
|
||||||
"allLibraries": "Permitir todas las bibliotecas",
|
"allLibraries": "Permitir todas las bibliotecas",
|
||||||
"selectedLibraries": "Bibliotecas seleccionadas",
|
"selectedLibraries": "Bibliotecas seleccionadas",
|
||||||
"allowWriteAccess": ""
|
"allowWriteAccess": "Permitir acceso de escritura"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
@ -401,7 +401,7 @@
|
|||||||
"requiredHosts": "Hosts requeridos",
|
"requiredHosts": "Hosts requeridos",
|
||||||
"configValidationError": "La validación de la configuración falló:",
|
"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.",
|
"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": {
|
"placeholders": {
|
||||||
"configKey": "clave",
|
"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.",
|
"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",
|
"noSimilarSongsFound": "No se encontraron canciones similares",
|
||||||
"noTopSongsFound": "No se encontraron canciones destacadas",
|
"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": {
|
"menu": {
|
||||||
"library": "Biblioteca",
|
"library": "Biblioteca",
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"bitDepth": "Bit-sakonera",
|
"bitDepth": "Bit-sakonera",
|
||||||
"sampleRate": "Lagin-tasa",
|
"sampleRate": "Lagin-tasa",
|
||||||
"channels": "Kanalak",
|
"channels": "Kanalak",
|
||||||
|
"disc": "%{discNumber}. diskoa",
|
||||||
"discSubtitle": "Diskoaren azpititulua",
|
"discSubtitle": "Diskoaren azpititulua",
|
||||||
"starred": "Gogokoa",
|
"starred": "Gogokoa",
|
||||||
"comment": "Iruzkina",
|
"comment": "Iruzkina",
|
||||||
@ -355,7 +356,8 @@
|
|||||||
"allUsers": "Baimendu erabiltzaile guztiak",
|
"allUsers": "Baimendu erabiltzaile guztiak",
|
||||||
"selectedUsers": "Hautatutako erabiltzaileak",
|
"selectedUsers": "Hautatutako erabiltzaileak",
|
||||||
"allLibraries": "Baimendu liburutegi guztiak",
|
"allLibraries": "Baimendu liburutegi guztiak",
|
||||||
"selectedLibraries": "Hautatutako liburutegiak"
|
"selectedLibraries": "Hautatutako liburutegiak",
|
||||||
|
"allowWriteAccess": "Eman idazteko baimena"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Egoera",
|
"status": "Egoera",
|
||||||
@ -400,6 +402,7 @@
|
|||||||
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
|
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
|
||||||
"noLibraries": "Ez da liburutegirik hautatu",
|
"noLibraries": "Ez da liburutegirik hautatu",
|
||||||
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
|
"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"
|
"requiredHosts": "Beharrezko ostatatzaileak"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@ -554,6 +557,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"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",
|
"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.",
|
"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.",
|
"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",
|
"currentValue": "Uneko balioa",
|
||||||
"configurationFile": "Konfigurazio-fitxategia",
|
"configurationFile": "Konfigurazio-fitxategia",
|
||||||
"exportToml": "Esportatu konfigurazioa (TOML)",
|
"exportToml": "Esportatu konfigurazioa (TOML)",
|
||||||
|
"downloadToml": "Deskargatu konfigurazioa (TOML)",
|
||||||
"exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan",
|
"exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan",
|
||||||
"exportFailed": "Konfigurazioa kopiatzeak huts egin du",
|
"exportFailed": "Konfigurazioa kopiatzeak huts egin du",
|
||||||
"devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)",
|
"devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)",
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
"missing": "Puuttuva",
|
"missing": "Puuttuva",
|
||||||
"libraryName": "Kirjasto",
|
"libraryName": "Kirjasto",
|
||||||
"composer": "Säveltäjä",
|
"composer": "Säveltäjä",
|
||||||
"disc": ""
|
"disc": "Levy %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Lisää jonoon",
|
"addToQueue": "Lisää jonoon",
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
"missing": "Manquant",
|
"missing": "Manquant",
|
||||||
"libraryName": "Bibliothèque",
|
"libraryName": "Bibliothèque",
|
||||||
"composer": "Compositeur·e",
|
"composer": "Compositeur·e",
|
||||||
"disc": ""
|
"disc": "Disque %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Ajouter à la file",
|
"addToQueue": "Ajouter à la file",
|
||||||
@ -355,7 +355,7 @@
|
|||||||
"selectedUsers": "Utilisateur·rices sélectionné.e.s",
|
"selectedUsers": "Utilisateur·rices sélectionné.e.s",
|
||||||
"allLibraries": "Autoriser toutes les bibliothèques",
|
"allLibraries": "Autoriser toutes les bibliothèques",
|
||||||
"selectedLibraries": "Bibliothèques sélectionnées",
|
"selectedLibraries": "Bibliothèques sélectionnées",
|
||||||
"allowWriteAccess": ""
|
"allowWriteAccess": "Autoriser l'accès en écriture"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
@ -401,7 +401,7 @@
|
|||||||
"requiredHosts": "Hôtes requis",
|
"requiredHosts": "Hôtes requis",
|
||||||
"configValidationError": "Erreur lors de la validation de la configuration",
|
"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.",
|
"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": {
|
"placeholders": {
|
||||||
"configKey": "clef",
|
"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.",
|
"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é",
|
"noSimilarSongsFound": "Aucun titre similaire n'a été trouvé",
|
||||||
"noTopSongsFound": "Aucun meilleur titre 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": {
|
"menu": {
|
||||||
"library": "Bibliothèque",
|
"library": "Bibliothèque",
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
"missing": "Falta",
|
"missing": "Falta",
|
||||||
"libraryName": "Biblioteca",
|
"libraryName": "Biblioteca",
|
||||||
"composer": "Composición",
|
"composer": "Composición",
|
||||||
"disc": ""
|
"disc": "Disco %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Ao final da cola",
|
"addToQueue": "Ao final da cola",
|
||||||
@ -355,7 +355,7 @@
|
|||||||
"selectedUsers": "Usuarias seleccionadas",
|
"selectedUsers": "Usuarias seleccionadas",
|
||||||
"allLibraries": "Permitir todas as bibliotecas",
|
"allLibraries": "Permitir todas as bibliotecas",
|
||||||
"selectedLibraries": "Selecciona bibliotecas",
|
"selectedLibraries": "Selecciona bibliotecas",
|
||||||
"allowWriteAccess": ""
|
"allowWriteAccess": "Conceder acceso de escritura"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
@ -401,7 +401,7 @@
|
|||||||
"requiredHosts": "Servidores requeridos",
|
"requiredHosts": "Servidores requeridos",
|
||||||
"configValidationError": "Fallou a comprobación da configuración:",
|
"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.",
|
"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": {
|
"placeholders": {
|
||||||
"configKey": "clave",
|
"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.",
|
"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",
|
"noSimilarSongsFound": "Sen cancións parecidas",
|
||||||
"noTopSongsFound": "Sen cancións destacadas",
|
"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": {
|
"menu": {
|
||||||
"library": "Biblioteca",
|
"library": "Biblioteca",
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
"bitRate": "Bitráta",
|
"bitRate": "Bitráta",
|
||||||
"bitDepth": "Bitmélység",
|
"bitDepth": "Bitmélység",
|
||||||
"sampleRate": "Mintavételezési frekvencia",
|
"sampleRate": "Mintavételezési frekvencia",
|
||||||
|
"disc": "Lemez %{discNumber}",
|
||||||
"discSubtitle": "Lemezfelirat",
|
"discSubtitle": "Lemezfelirat",
|
||||||
"starred": "Kedvenc",
|
"starred": "Kedvenc",
|
||||||
"comment": "Megjegyzés",
|
"comment": "Megjegyzés",
|
||||||
@ -350,7 +351,8 @@
|
|||||||
"allUsers": "Összes felhasználó engedélyezése",
|
"allUsers": "Összes felhasználó engedélyezése",
|
||||||
"selectedUsers": "Kiválasztott felhasználók engedélyezése",
|
"selectedUsers": "Kiválasztott felhasználók engedélyezése",
|
||||||
"allLibraries": "Összes könyvtár 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": {
|
"sections": {
|
||||||
"status": "Státusz",
|
"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.",
|
"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",
|
"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.",
|
"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"
|
"requiredHosts": "Szükséges hostok"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@ -549,6 +552,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"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",
|
"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.",
|
"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.",
|
"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",
|
"sampleRate": "Sample waarde",
|
||||||
"missing": "Ontbrekend",
|
"missing": "Ontbrekend",
|
||||||
"libraryName": "Bibliotheek",
|
"libraryName": "Bibliotheek",
|
||||||
"composer": ""
|
"composer": "Componist",
|
||||||
|
"disc": "Schijf %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Voeg toe aan wachtrij",
|
"addToQueue": "Voeg toe aan wachtrij",
|
||||||
@ -48,7 +49,7 @@
|
|||||||
"playNext": "Volgende",
|
"playNext": "Volgende",
|
||||||
"info": "Meer info",
|
"info": "Meer info",
|
||||||
"showInPlaylist": "Toon in afspeellijst",
|
"showInPlaylist": "Toon in afspeellijst",
|
||||||
"instantMix": ""
|
"instantMix": "Instant mix"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"album": {
|
"album": {
|
||||||
@ -350,10 +351,11 @@
|
|||||||
"createdAt": "Geinstalleerd",
|
"createdAt": "Geinstalleerd",
|
||||||
"configKey": "Sleutel",
|
"configKey": "Sleutel",
|
||||||
"configValue": "Waarde",
|
"configValue": "Waarde",
|
||||||
"allUsers": "Alle gebruikers toelaten",
|
"allUsers": "Sta toe voor alle gebruikers",
|
||||||
"selectedUsers": "Geselecteerde gebruikers",
|
"selectedUsers": "Geselecteerde gebruikers",
|
||||||
"allLibraries": "Alle bibliotheken toestaan",
|
"allLibraries": "Sta toe voor alle bibliotheken",
|
||||||
"selectedLibraries": "Geselecteerde bibliotheken"
|
"selectedLibraries": "Geselecteerde bibliotheken",
|
||||||
|
"allowWriteAccess": "Sta schrijftoegang toe"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@ -379,26 +381,27 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"enabled": "Plugin actief",
|
"enabled": "Plugin actief",
|
||||||
"disabled": "Plugin niet actief",
|
"disabled": "Plugin niet actief",
|
||||||
"updated": "Plugin geupdate",
|
"updated": "Plugin bijgewerkt",
|
||||||
"error": "Fout bij updaten plugin"
|
"error": "Fout bij updaten plugin"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"invalidJson": "Configuratie moet geldige JSON zijn"
|
"invalidJson": "Configuratie moet geldige JSON zijn"
|
||||||
},
|
},
|
||||||
"messages": {
|
"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",
|
"clickPermissions": "Klik op permissie voor details",
|
||||||
"noConfig": "Geen configuratie ingesteld",
|
"noConfig": "Geen configuratie ingesteld",
|
||||||
"allUsersHelp": "",
|
"allUsersHelp": "Als dit aanstaat heeft de plug-in toegang tot alle gebruikers, inclusief toekomstige.",
|
||||||
"noUsers": "Geen gebruikers geselecteerd",
|
"noUsers": "Geen gebruikers geselecteerd",
|
||||||
"permissionReason": "Reden",
|
"permissionReason": "Reden",
|
||||||
"usersRequired": "",
|
"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": "",
|
"allLibrariesHelp": "Als dit aanstaat, heeft de plug-in toegang tot alle bibliotheken, inclusief toekomstige.",
|
||||||
"noLibraries": "Geen bibliotheken geselecteerd",
|
"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",
|
"requiredHosts": "Benodigde hosts",
|
||||||
"configValidationError": "",
|
"configValidationError": "Configuratiecheck mislukt",
|
||||||
"schemaRenderError": ""
|
"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": {
|
"placeholders": {
|
||||||
"configKey": "Sleutel",
|
"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.",
|
"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",
|
"noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
|
||||||
"noTopSongsFound": "Geen beste 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": {
|
"menu": {
|
||||||
"library": "Bibliotheek",
|
"library": "Bibliotheek",
|
||||||
@ -674,7 +683,8 @@
|
|||||||
"exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
|
"exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
|
||||||
"exportFailed": "Kopiëren van configuratie mislukt",
|
"exportFailed": "Kopiëren van configuratie mislukt",
|
||||||
"devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)",
|
"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": {
|
"activity": {
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Taxa de amostragem",
|
"sampleRate": "Taxa de amostragem",
|
||||||
"missing": "Ausente",
|
"missing": "Ausente",
|
||||||
"libraryName": "Biblioteca",
|
"libraryName": "Biblioteca",
|
||||||
"composer": "Compositor"
|
"composer": "Compositor",
|
||||||
|
"disc": "Disco %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Adicionar à fila",
|
"addToQueue": "Adicionar à fila",
|
||||||
@ -397,10 +398,10 @@
|
|||||||
"allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.",
|
"allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.",
|
||||||
"noLibraries": "Nenhuma biblioteca selecionada",
|
"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'.",
|
"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",
|
"requiredHosts": "Hosts necessários",
|
||||||
"configValidationError": "Falha na validação da configuração:",
|
"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": {
|
"placeholders": {
|
||||||
"configKey": "chave",
|
"configKey": "chave",
|
||||||
@ -554,12 +555,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"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",
|
"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}",
|
"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",
|
"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.",
|
"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",
|
"noSimilarSongsFound": "Nenhuma música semelhante encontrada",
|
||||||
"noTopSongsFound": "Nenhuma música mais tocada 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": {
|
"menu": {
|
||||||
"library": "Biblioteca",
|
"library": "Biblioteca",
|
||||||
|
|||||||
723
resources/i18n/sk.json
Normal file
723
resources/i18n/sk.json
Normal file
@ -0,0 +1,723 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Slovenčina",
|
||||||
|
"resources": {
|
||||||
|
"song": {
|
||||||
|
"name": "Skladba |||| Skladieb",
|
||||||
|
"fields": {
|
||||||
|
"albumArtist": "Interpret albumu",
|
||||||
|
"duration": "Dĺžka",
|
||||||
|
"trackNumber": "#",
|
||||||
|
"playCount": "Počet prehratí",
|
||||||
|
"title": "Názov",
|
||||||
|
"artist": "Interpret",
|
||||||
|
"composer": "Skladateľ",
|
||||||
|
"album": "Album",
|
||||||
|
"path": "Cesta k súboru",
|
||||||
|
"libraryName": "Knižnica",
|
||||||
|
"genre": "Žáner",
|
||||||
|
"compilation": "Kompilácia",
|
||||||
|
"year": "Rok",
|
||||||
|
"size": "Veľkosť súboru",
|
||||||
|
"updatedAt": "Nahrané",
|
||||||
|
"bitRate": "Prenosová rýchlosť",
|
||||||
|
"bitDepth": "Bitová hĺbka",
|
||||||
|
"sampleRate": "Vzorkovacia frekvencia",
|
||||||
|
"channels": "Kanály",
|
||||||
|
"disc": "Disk %{discNumber}",
|
||||||
|
"discSubtitle": "Podtitul disku",
|
||||||
|
"starred": "Obľúbené",
|
||||||
|
"comment": "Komentár",
|
||||||
|
"rating": "Hodnotenie",
|
||||||
|
"quality": "Kvalita",
|
||||||
|
"bpm": "BPM",
|
||||||
|
"playDate": "Naposledy prehraná skladba",
|
||||||
|
"createdAt": "Pridané",
|
||||||
|
"grouping": "Zoskupovanie",
|
||||||
|
"mood": "Nálada",
|
||||||
|
"participants": "Ďalší účastníci",
|
||||||
|
"tags": "Ďalšie značky",
|
||||||
|
"mappedTags": "Mapované značky",
|
||||||
|
"rawTags": "Nespracované značky",
|
||||||
|
"missing": "Chýbajúce"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"addToQueue": "Prehrať neskôr",
|
||||||
|
"playNow": "Prehrať teraz",
|
||||||
|
"addToPlaylist": "Pridať do zoznamu skladieb",
|
||||||
|
"showInPlaylist": "Zobraziť v zozname skladieb",
|
||||||
|
"shuffleAll": "Zamiešať všetko",
|
||||||
|
"download": "Stiahnuť",
|
||||||
|
"playNext": "Prehrať ako ďalšie",
|
||||||
|
"info": "Získať informácie",
|
||||||
|
"instantMix": "Okamžitý mix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"name": "Album |||| Albumy",
|
||||||
|
"fields": {
|
||||||
|
"albumArtist": "Interpret albumu",
|
||||||
|
"artist": "Interpret",
|
||||||
|
"duration": "Dĺžka",
|
||||||
|
"songCount": "Skladby",
|
||||||
|
"playCount": "Počet prehratí",
|
||||||
|
"size": "Veľkosť",
|
||||||
|
"name": "Názov",
|
||||||
|
"libraryName": "Knižnica",
|
||||||
|
"genre": "Žáner",
|
||||||
|
"compilation": "Kompilácia",
|
||||||
|
"year": "Rok",
|
||||||
|
"date": "Dátum záznamu",
|
||||||
|
"originalDate": "Pôvodné",
|
||||||
|
"releaseDate": "Vydané",
|
||||||
|
"releases": "Vydanie |||| Vydania",
|
||||||
|
"released": "Vydané",
|
||||||
|
"updatedAt": "Aktualizované",
|
||||||
|
"comment": "Komentár",
|
||||||
|
"rating": "Hodnotenie",
|
||||||
|
"createdAt": "Pridané",
|
||||||
|
"recordLabel": "Štítok",
|
||||||
|
"catalogNum": "Katalógové číslo",
|
||||||
|
"releaseType": "Typ vydania",
|
||||||
|
"grouping": "Zoskupovanie",
|
||||||
|
"media": "Médiá",
|
||||||
|
"mood": "Nálada",
|
||||||
|
"missing": "Chýbajúce"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"playAll": "Prehrať",
|
||||||
|
"playNext": "Prehrať ako ďalšie",
|
||||||
|
"addToQueue": "Prehrať neskôr",
|
||||||
|
"share": "Zdieľať",
|
||||||
|
"shuffle": "Zamiešať",
|
||||||
|
"addToPlaylist": "Pridať do zoznamu skladieb",
|
||||||
|
"download": "Stiahnuť",
|
||||||
|
"info": "Získať informácie"
|
||||||
|
},
|
||||||
|
"lists": {
|
||||||
|
"all": "Všetko",
|
||||||
|
"random": "Náhodné",
|
||||||
|
"recentlyAdded": "Nedávno pridané",
|
||||||
|
"recentlyPlayed": "Nedávno prehrané",
|
||||||
|
"mostPlayed": "Najviac prehrávané",
|
||||||
|
"starred": "Obľúbené",
|
||||||
|
"topRated": "Najlepšie hodnotené"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"name": "Interpret |||| Interpreti",
|
||||||
|
"fields": {
|
||||||
|
"name": "Názov",
|
||||||
|
"albumCount": "Počet albumov",
|
||||||
|
"songCount": "Počet skladieb",
|
||||||
|
"size": "Veľkosť",
|
||||||
|
"playCount": "Prehrania",
|
||||||
|
"rating": "Hodnotenie",
|
||||||
|
"genre": "Žáner",
|
||||||
|
"role": "Rola",
|
||||||
|
"missing": "Chýbajúci"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"albumartist": "Interpret albumu |||| Interpreti albumov",
|
||||||
|
"artist": "Interpret |||| Interpreti",
|
||||||
|
"composer": "Skladateľ |||| Skladatelia",
|
||||||
|
"conductor": "Dirigent |||| Dirigenti",
|
||||||
|
"lyricist": "Textár |||| Textári",
|
||||||
|
"arranger": "Aranžér |||| Aranžéri",
|
||||||
|
"producer": "Producent |||| Producenti",
|
||||||
|
"director": "Režisér |||| Režiséri",
|
||||||
|
"engineer": "Zvukový technik |||| Zvukoví technici",
|
||||||
|
"mixer": "Mixér |||| Mixéri",
|
||||||
|
"remixer": "Remixér |||| Remixéri",
|
||||||
|
"djmixer": "DJ Mixér |||| DJ Mixéri",
|
||||||
|
"performer": "Účinkujúci |||| Účinkujúci",
|
||||||
|
"maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"topSongs": "Najpopulárnejšie skladby",
|
||||||
|
"shuffle": "Zamiešať",
|
||||||
|
"radio": "Rádio"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "Používateľ |||| Používatelia",
|
||||||
|
"fields": {
|
||||||
|
"userName": "Používateľské meno",
|
||||||
|
"isAdmin": "Správca",
|
||||||
|
"lastLoginAt": "Naposledy prihlásený",
|
||||||
|
"lastAccessAt": "Posledný Prístup",
|
||||||
|
"updatedAt": "Upravený",
|
||||||
|
"name": "Meno",
|
||||||
|
"password": "Heslo",
|
||||||
|
"createdAt": "Vytvorený",
|
||||||
|
"changePassword": "Zmeniť heslo?",
|
||||||
|
"currentPassword": "Súčastné heslo",
|
||||||
|
"newPassword": "Nové heslo",
|
||||||
|
"token": "Token",
|
||||||
|
"libraries": "Knižnice"
|
||||||
|
},
|
||||||
|
"helperTexts": {
|
||||||
|
"name": "Zmena mena sa zobrazí až po ďalšom prihlásení",
|
||||||
|
"libraries": "Vyberte konkrétne knižnice pre tohto používateľa alebo nechajte pole prázdne, ak chcete použiť predvolené knižnice"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"created": "Používateľ vytvorený",
|
||||||
|
"updated": "Používateľ upravený",
|
||||||
|
"deleted": "Používateľ odstránený"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"listenBrainzToken": "Vložte svoj používateľský ListenBrainz token.",
|
||||||
|
"clickHereForToken": "Kliknite sem pre získanie svojho tokenu",
|
||||||
|
"selectAllLibraries": "Vybrať všetky knižnice",
|
||||||
|
"adminAutoLibraries": "Administrátori majú automaticky prístup ku všetkým knižniciam"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"name": "Prehrávač |||| Prehrávače",
|
||||||
|
"fields": {
|
||||||
|
"name": "Názov",
|
||||||
|
"transcodingId": "ID transkódovania",
|
||||||
|
"maxBitRate": "Max. prenosová rýchlosť",
|
||||||
|
"client": "Klient",
|
||||||
|
"userName": "Používateľské meno",
|
||||||
|
"lastSeen": "Naposledy videný",
|
||||||
|
"reportRealPath": "Skutočná cesta hlásenia",
|
||||||
|
"scrobbleEnabled": "Odosielať scrobbling na externé služby"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transcoding": {
|
||||||
|
"name": "Transkódovanie |||| Transkódovania",
|
||||||
|
"fields": {
|
||||||
|
"name": "Názov",
|
||||||
|
"targetFormat": "Cieľový formát",
|
||||||
|
"defaultBitRate": "Predvolená prenosová rýchlosť",
|
||||||
|
"command": "Príkaz"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"name": "Zoznam skladieb |||| Zoznamy skladieb",
|
||||||
|
"fields": {
|
||||||
|
"name": "Názov",
|
||||||
|
"duration": "Dĺžka",
|
||||||
|
"ownerName": "Autor",
|
||||||
|
"public": "Verejný",
|
||||||
|
"updatedAt": "Nahraný",
|
||||||
|
"createdAt": "Vytvorený",
|
||||||
|
"songCount": "Skladby",
|
||||||
|
"comment": "Komentár",
|
||||||
|
"sync": "Auto-import",
|
||||||
|
"path": "Importovať z"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"selectPlaylist": "Vybrať zoznam skladieb:",
|
||||||
|
"addNewPlaylist": "Vytvoriť \"%{name}\"",
|
||||||
|
"export": "Export",
|
||||||
|
"saveQueue": "Uložiť rad do zoznamu skladieb",
|
||||||
|
"makePublic": "Zverejniť",
|
||||||
|
"makePrivate": "Nastaviť ako súkromné",
|
||||||
|
"searchOrCreate": "Vyhľadajte zoznamy skladieb alebo napíšte pre vytvorenie nového...",
|
||||||
|
"pressEnterToCreate": "Stlačte Enter pre vytvorenie nového zoznamu skladieb",
|
||||||
|
"removeFromSelection": "Odstrániť z výberu"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"duplicate_song": "Pridať duplicitné položky",
|
||||||
|
"song_exist": "Pridávate duplikát už existujúcej položky v zozname skladieb. Chcete pridať duplikát alebo ho preskočiť?",
|
||||||
|
"noPlaylistsFound": "Žiadne zoznamy skladieb sa nenašli",
|
||||||
|
"noPlaylists": "Žiadne zoznamy skladieb nie sú dostupné"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"radio": {
|
||||||
|
"name": "Rádio |||| Rádiá",
|
||||||
|
"fields": {
|
||||||
|
"name": "Názov",
|
||||||
|
"streamUrl": "URL streamu",
|
||||||
|
"homePageUrl": "URL stránky",
|
||||||
|
"updatedAt": "Nahrané",
|
||||||
|
"createdAt": "Vytvorené"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"playNow": "Spustiť"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"share": {
|
||||||
|
"name": "Zdieľanie |||| Zdieľania",
|
||||||
|
"fields": {
|
||||||
|
"username": "Zdieľané",
|
||||||
|
"url": "URL",
|
||||||
|
"description": "Popis",
|
||||||
|
"downloadable": "Povoliť sťahovanie?",
|
||||||
|
"contents": "Obsah",
|
||||||
|
"expiresAt": "Vyprší",
|
||||||
|
"lastVisitedAt": "Naposledy navštívené",
|
||||||
|
"visitCount": "Počet návštev",
|
||||||
|
"format": "Formát",
|
||||||
|
"maxBitRate": "Max. Bit Rate",
|
||||||
|
"updatedAt": "Nahrané",
|
||||||
|
"createdAt": "Vytvorené"
|
||||||
|
},
|
||||||
|
"notifications": {},
|
||||||
|
"actions": {}
|
||||||
|
},
|
||||||
|
"missing": {
|
||||||
|
"name": "Chýbajúci súbor |||| Chýbajúce súbory",
|
||||||
|
"empty": "Žiadne chýbajúce súbory",
|
||||||
|
"fields": {
|
||||||
|
"path": "Cesta",
|
||||||
|
"size": "Veľkosť",
|
||||||
|
"libraryName": "Knižnica",
|
||||||
|
"updatedAt": "Zmizol dňa"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"remove": "Odstrániť",
|
||||||
|
"remove_all": "Odstrániť všetky"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"removed": "Chýbajúce súbory odstránené"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"name": "Knižnica |||| Knižnice",
|
||||||
|
"fields": {
|
||||||
|
"name": "Názov",
|
||||||
|
"path": "Cesta",
|
||||||
|
"remotePath": "Vzdialená cesta",
|
||||||
|
"lastScanAt": "Posledný sken",
|
||||||
|
"songCount": "Skladby",
|
||||||
|
"albumCount": "Albumy",
|
||||||
|
"artistCount": "Interpreti",
|
||||||
|
"totalSongs": "Skladby",
|
||||||
|
"totalAlbums": "Albumy",
|
||||||
|
"totalArtists": "Interpreti",
|
||||||
|
"totalFolders": "Priečinky",
|
||||||
|
"totalFiles": "Súbory",
|
||||||
|
"totalMissingFiles": "Chýbajúce súbory",
|
||||||
|
"totalSize": "Celková veľkosť",
|
||||||
|
"totalDuration": "Dĺžka",
|
||||||
|
"defaultNewUsers": "Predvolené pre nových používateľov",
|
||||||
|
"createdAt": "Vytvorené",
|
||||||
|
"updatedAt": "Aktualizované"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"basic": "Základné informácie",
|
||||||
|
"statistics": "Štatistiky"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"scan": "Skenovať knižnicu",
|
||||||
|
"quickScan": "Rýchly sken",
|
||||||
|
"fullScan": "Úplný sken",
|
||||||
|
"manageUsers": "Spravovať prístup používateľov",
|
||||||
|
"viewDetails": "Zobraziť detaily"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"created": "Knižnica úspešne vytvorená",
|
||||||
|
"updated": "Knižnica úspešne aktualizovaná",
|
||||||
|
"deleted": "Knižnica úspešne odstránená",
|
||||||
|
"scanStarted": "Skenovanie knižnice spustené",
|
||||||
|
"quickScanStarted": "Rýchly sken spustený",
|
||||||
|
"fullScanStarted": "Úplný sken spustený",
|
||||||
|
"scanError": "Chyba pri spustení skenu. Skontrolujte logy",
|
||||||
|
"scanCompleted": "Skenovanie knižnice dokončené"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Názov knižnice je povinný",
|
||||||
|
"pathRequired": "Cesta ku knižnici je povinná",
|
||||||
|
"pathNotDirectory": "Cesta ku knižnici musí byť priečinok",
|
||||||
|
"pathNotFound": "Cesta ku knižnici sa nenašla",
|
||||||
|
"pathNotAccessible": "Cesta ku knižnici nie je dostupná",
|
||||||
|
"pathInvalid": "Neplatná cesta ku knižnici"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"deleteConfirm": "Ste si istý, že chcete odstrániť túto knižnicu? Tým sa odstránia všetky súvisiace dáta a prístupy používateľov.",
|
||||||
|
"scanInProgress": "Skenovanie prebieha...",
|
||||||
|
"noLibrariesAssigned": "Tomuto používateľovi nie sú priradené žiadne knižnice"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugin": {
|
||||||
|
"name": "Plugin |||| Pluginy",
|
||||||
|
"fields": {
|
||||||
|
"id": "ID",
|
||||||
|
"name": "Názov",
|
||||||
|
"description": "Popis",
|
||||||
|
"version": "Verzia",
|
||||||
|
"author": "Autor",
|
||||||
|
"website": "Webová stránka",
|
||||||
|
"permissions": "Oprávnenia",
|
||||||
|
"enabled": "Povolený",
|
||||||
|
"status": "Stav",
|
||||||
|
"path": "Cesta",
|
||||||
|
"lastError": "Chyba",
|
||||||
|
"hasError": "Chyba",
|
||||||
|
"updatedAt": "Aktualizovaný",
|
||||||
|
"createdAt": "Nainštalovaný",
|
||||||
|
"configKey": "Kľúč",
|
||||||
|
"configValue": "Hodnota",
|
||||||
|
"allUsers": "Povoliť všetkých používateľov",
|
||||||
|
"selectedUsers": "Vybraní používatelia",
|
||||||
|
"allLibraries": "Povoliť všetky knižnice",
|
||||||
|
"selectedLibraries": "Vybrané knižnice",
|
||||||
|
"allowWriteAccess": "Povoliť prístup na zápis"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"status": "Stav",
|
||||||
|
"info": "Informácie o plugine",
|
||||||
|
"configuration": "Konfigurácia",
|
||||||
|
"manifest": "Manifest",
|
||||||
|
"usersPermission": "Oprávnenia používateľov",
|
||||||
|
"libraryPermission": "Oprávnenia knižnice"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enabled": "Povolený",
|
||||||
|
"disabled": "Zakázaný"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"enable": "Povoliť",
|
||||||
|
"disable": "Zakázať",
|
||||||
|
"disabledDueToError": "Opravte chybu pred povolením",
|
||||||
|
"disabledUsersRequired": "Vyberte používateľov pred povolením",
|
||||||
|
"disabledLibrariesRequired": "Vyberte knižnice pred povolením",
|
||||||
|
"addConfig": "Pridať konfiguráciu",
|
||||||
|
"rescan": "Znovu skenovať"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"enabled": "Plugin povolený",
|
||||||
|
"disabled": "Plugin zakázaný",
|
||||||
|
"updated": "Plugin aktualizovaný",
|
||||||
|
"error": "Chyba pri aktualizácii pluginu"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"invalidJson": "Konfigurácia musí byť platný JSON"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"configHelp": "Nakonfigurujte plugin pomocou párov kľúč-hodnota. Nechajte prázdne, ak plugin nevyžaduje žiadnu konfiguráciu.",
|
||||||
|
"configValidationError": "Overenie konfigurácie zlyhalo:",
|
||||||
|
"schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.",
|
||||||
|
"clickPermissions": "Kliknite na oprávnenie pre detaily",
|
||||||
|
"noConfig": "Žiadna konfigurácia nastavená",
|
||||||
|
"allUsersHelp": "Keď je povolené, plugin bude mať prístup ku všetkým používateľom, vrátane tých vytvorených v budúcnosti.",
|
||||||
|
"noUsers": "Žiadni používatelia nevybraní",
|
||||||
|
"permissionReason": "Dôvod",
|
||||||
|
"usersRequired": "Tento plugin vyžaduje prístup k informáciám o používateľoch. Vyberte, ku ktorým používateľom má plugin prístup, alebo povolte 'Povoliť všetkých používateľov'.",
|
||||||
|
"allLibrariesHelp": "Keď je povolené, plugin bude mať prístup ku všetkým knižniciam, vrátane tých vytvorených v budúcnosti.",
|
||||||
|
"noLibraries": "Žiadne knižnice nevybrané",
|
||||||
|
"librariesRequired": "Tento plugin vyžaduje prístup k informáciám o knižniciach. Vyberte, ku ktorým knižniciam má plugin prístup, alebo povolte 'Povoliť všetky knižnice'.",
|
||||||
|
"allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie.",
|
||||||
|
"requiredHosts": "Požadovaní hostitelia"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"configKey": "kľúč",
|
||||||
|
"configValue": "hodnota"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ra": {
|
||||||
|
"auth": {
|
||||||
|
"welcome1": "Ďakujeme, že ste si nainštalovali Navidrome!",
|
||||||
|
"welcome2": "Najskôr vytvorte účet správcu",
|
||||||
|
"confirmPassword": "Potvrďte heslo",
|
||||||
|
"buttonCreateAdmin": "Vytvoriť správcu",
|
||||||
|
"auth_check_error": "Pre pokračovanie sa prosím prihláste",
|
||||||
|
"user_menu": "Profil",
|
||||||
|
"username": "Používateľské meno",
|
||||||
|
"password": "Heslo",
|
||||||
|
"sign_in": "Prihlásiť sa",
|
||||||
|
"sign_in_error": "Overenie zlyhalo, skúste to znova",
|
||||||
|
"logout": "Odhlásiť sa",
|
||||||
|
"insightsCollectionNote": "Navidrome zhromažďuje anonymné údaje\n o používaní, aby pomohol zlepšiť projekt.\nKliknite [sem] a dozviete sa viac a v prípade\npotreby sa odhláste."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"invalidChars": "Prosím, používajte iba písmená a čísla",
|
||||||
|
"passwordDoesNotMatch": "Heslá sa nezhodujú",
|
||||||
|
"required": "Povinné pole",
|
||||||
|
"minLength": "Musí obsahovať najmenej %{min} znakov",
|
||||||
|
"maxLength": "Môže obsahovať maximálne %{max} znakov",
|
||||||
|
"minValue": "Musí byť aspoň %{min}",
|
||||||
|
"maxValue": "Môže byť maximálne %{max}",
|
||||||
|
"number": "Musí byť číslo",
|
||||||
|
"email": "Musí byť platná e-mailová adresa",
|
||||||
|
"oneOf": "Musí spĺňať jedno z: %{options}",
|
||||||
|
"regex": "Musí byť v špecifickom formáte (regexp): %{pattern}",
|
||||||
|
"unique": "Musí byť jedinečný",
|
||||||
|
"url": "Musí byť platná URL"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"add_filter": "Pridať filter",
|
||||||
|
"add": "Pridať",
|
||||||
|
"back": "Ísť späť",
|
||||||
|
"bulk_actions": "1 vybraná |||| %{smart_count} vybraných",
|
||||||
|
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||||
|
"cancel": "Zrušiť",
|
||||||
|
"clear_input_value": "Vymazať hodnotu",
|
||||||
|
"clone": "Klonovať",
|
||||||
|
"confirm": "Potvrdiť",
|
||||||
|
"create": "Vytvoriť",
|
||||||
|
"delete": "Vymazať",
|
||||||
|
"edit": "Upraviť",
|
||||||
|
"export": "Exportovať",
|
||||||
|
"list": "Zoznam",
|
||||||
|
"refresh": "Obnoviť",
|
||||||
|
"remove_filter": "Odstrániť filter",
|
||||||
|
"remove": "Odstrániť",
|
||||||
|
"save": "Uložiť",
|
||||||
|
"search": "Vyhľadať",
|
||||||
|
"show": "Zobraziť",
|
||||||
|
"sort": "Zoradiť",
|
||||||
|
"undo": "Vrátiť",
|
||||||
|
"expand": "Rozbaliť",
|
||||||
|
"close": "Zavrieť",
|
||||||
|
"open_menu": "Otvoriť ponuku",
|
||||||
|
"close_menu": "Zavrieť ponuku",
|
||||||
|
"unselect": "Zrušiť výber",
|
||||||
|
"skip": "Preskočiť",
|
||||||
|
"share": "Zdieľať",
|
||||||
|
"download": "Stiahnuť"
|
||||||
|
},
|
||||||
|
"boolean": {
|
||||||
|
"true": "Áno",
|
||||||
|
"false": "Nie"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"create": "Vytvoriť %{name}",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"edit": "%{name} #%{id}",
|
||||||
|
"error": "Niečo sa pokazilo",
|
||||||
|
"list": "%{name}",
|
||||||
|
"loading": "Načítavanie",
|
||||||
|
"not_found": "Nenájdené",
|
||||||
|
"show": "%{name} #%{id}",
|
||||||
|
"empty": "Zatiaľ žiaden %{name}.",
|
||||||
|
"invite": "Chcete pridať nové?"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"file": {
|
||||||
|
"upload_several": "Presuňte súbory pre nahranie alebo kliknite pre výber.",
|
||||||
|
"upload_single": "Presuňte súbor pre nahranie alebo kliknite pre jeho výber."
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"upload_several": "Presuňte obrázky pre nahranie alebo kliknite pre výber.",
|
||||||
|
"upload_single": "Presuňte obrázok pre nahranie alebo kliknite pre jeho výber."
|
||||||
|
},
|
||||||
|
"references": {
|
||||||
|
"all_missing": "Referencované dáta sa nenašli.",
|
||||||
|
"many_missing": "Aspoň jedna z referencií už nie je dostupná.",
|
||||||
|
"single_missing": "Referencia sa zdá byť nedostupná."
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"toggle_visible": "Skryť heslo",
|
||||||
|
"toggle_hidden": "Zobraziť heslo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"about": "O Navidrome",
|
||||||
|
"are_you_sure": "Ste si istý?",
|
||||||
|
"bulk_delete_content": "Ste si istý, že chcete vymazať %{name}? |||| Ste si istý, že chcete vymazať týchto %{smart_count} položiek?",
|
||||||
|
"bulk_delete_title": "Vymazať %{name} |||| Vymazať %{smart_count} %{name} položiek",
|
||||||
|
"delete_content": "Ste si istý, že chcete vymazať túto položku?",
|
||||||
|
"delete_title": "Vymazať %{name} #%{id}",
|
||||||
|
"details": "Detaily",
|
||||||
|
"error": "Vyskytla sa chyba klienta a vaša požiadavka nemohla byť splnená.",
|
||||||
|
"invalid_form": "Formulár nie je platný. Prosím skontrolujte ho.",
|
||||||
|
"loading": "Stránka sa načítava, prosím počkajte",
|
||||||
|
"no": "Nie",
|
||||||
|
"not_found": "Zadali ste nesprávnu adresu URL, alebo ste nasledovali nesprávny odkaz.",
|
||||||
|
"yes": "Áno",
|
||||||
|
"unsaved_changes": "Niektoré vaše zmeny neboli uložené. Ste si istí, že ich chcete ignorovať?"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"no_results": "Nenašli sa žiadne výsledky",
|
||||||
|
"no_more_results": "Stránka číslo %{page} je mimo rozsah. Skúste predchádzajúcu.",
|
||||||
|
"page_out_of_boundaries": "Stránka číslo %{page} je mimo rozsah",
|
||||||
|
"page_out_from_end": "Nemožno ísť za poslednú stranu",
|
||||||
|
"page_out_from_begin": "Nemožno ísť pred prvú stranu",
|
||||||
|
"page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}",
|
||||||
|
"page_rows_per_page": "Položiek na stránke:",
|
||||||
|
"next": "Ďalší",
|
||||||
|
"prev": "Predchádzajúci",
|
||||||
|
"skip_nav": "Preskočiť na obsah"
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"updated": "Prvok aktualizovaný |||| %{smart_count} prvkov aktualizovaných",
|
||||||
|
"created": "Prvok vytvorený",
|
||||||
|
"deleted": "Prvok vymazaný |||| %{smart_count} prvkov vymazaných",
|
||||||
|
"bad_item": "Nesprávny prvok",
|
||||||
|
"item_doesnt_exist": "Prvok neexistuje",
|
||||||
|
"http_error": "Chyba komunikácie servera",
|
||||||
|
"data_provider_error": "Chyba dataProvideru. Detaily nájdete v konzole.",
|
||||||
|
"i18n_error": "Nemožno načítať preklady pre vybraný jazyk",
|
||||||
|
"canceled": "Akcia zrušená",
|
||||||
|
"logged_out": "Vaša relácia skončila, prosím pripojte sa znova.",
|
||||||
|
"new_version": "Je dostupná nová verzia! Prosím obnovte toto okno."
|
||||||
|
},
|
||||||
|
"toggleFieldsMenu": {
|
||||||
|
"columnsToDisplay": "Stĺpce na zobrazenie",
|
||||||
|
"layout": "Rozloženie",
|
||||||
|
"grid": "Mriežka",
|
||||||
|
"table": "Tabuľka"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"uploadCover": "Nahrať obrázok obalu",
|
||||||
|
"removeCover": "Odstrániť obrázok obalu",
|
||||||
|
"coverUploaded": "Obrázok obalu albumu aktualizovaný",
|
||||||
|
"coverRemoved": "Obrázok obalu albumu odstránený",
|
||||||
|
"coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu",
|
||||||
|
"coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu",
|
||||||
|
"note": "POZNÁMKA",
|
||||||
|
"transcodingDisabled": "Zmena nastavení transkódovania je vo webovom prostredí vypnutá z bezpečnostných dôvodov. Ak chcete zmeniť (upraviť alebo pridať) možnosti transkódovania, reštartujte server s možnosťou %{config}.",
|
||||||
|
"transcodingEnabled": "Navidrome práve beží s možnosťou %{config}, ktorá umožňuje spúšťanie systémových príkazov z nastavení transkódovania pomocou webového rozhrania. Odporúčame ju vypnúť z bezpečnostných dôvodov a používať ju iba pri úprave nastavení transkódovania.",
|
||||||
|
"songsAddedToPlaylist": "1 skladba pridaná do zoznamu skladieb |||| %{smart_count} skladieb pridaných do zoznamu skladieb",
|
||||||
|
"noSimilarSongsFound": "Nenašli sa žiadne podobné skladby",
|
||||||
|
"startingInstantMix": "Načítava sa Instant Mix...",
|
||||||
|
"noTopSongsFound": "Nenašli sa žiadne top skladby",
|
||||||
|
"noPlaylistsAvailable": "Žiadne nie sú dostupné",
|
||||||
|
"delete_user_title": "Odstrániť používateľa '%{name}'",
|
||||||
|
"delete_user_content": "Ste si istí, že chcete odstrániť tohto používateľa a všetky jeho dáta (vrátane zoznamov skladieb a nastavení)?",
|
||||||
|
"remove_missing_title": "Odstráňte chýbajúce súbory",
|
||||||
|
"remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
|
||||||
|
"remove_all_missing_title": "Odstráňte všetky chýbajúce súbory",
|
||||||
|
"remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
|
||||||
|
"notifications_blocked": "Zablokovali ste si oznámenia pre túto stránku v nastaveniach vášho prehliadača",
|
||||||
|
"notifications_not_available": "Tento prehliadač nepodporuje oznámenia na ploche alebo nepristupujete k Navidrome cez https",
|
||||||
|
"lastfmLinkSuccess": "Last.fm úspešne pripojené a scrobbling zapnutý",
|
||||||
|
"lastfmLinkFailure": "Last.fm sa nepodarilo pripojiť",
|
||||||
|
"lastfmUnlinkSuccess": "Last.fm odpojené a scrobbling vypnutý",
|
||||||
|
"lastfmUnlinkFailure": "Last.fm sa nepodarilo odpojiť",
|
||||||
|
"listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}",
|
||||||
|
"listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}",
|
||||||
|
"listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý",
|
||||||
|
"listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť",
|
||||||
|
"openIn": {
|
||||||
|
"lastfm": "Otvoriť na Last.fm",
|
||||||
|
"musicbrainz": "Otvoriť na MusicBrainz"
|
||||||
|
},
|
||||||
|
"lastfmLink": "Čítať ďalej...",
|
||||||
|
"shareOriginalFormat": "Zdieľať v pôvodnom formáte",
|
||||||
|
"shareDialogTitle": "Zdieľať %{resource} '%{name}'",
|
||||||
|
"shareBatchDialogTitle": "Zdieľať 1 %{resource} |||| Zdieľať %{smart_count} %{resource}",
|
||||||
|
"shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter",
|
||||||
|
"shareSuccess": "URL skopírovaná do schránky: %{url}",
|
||||||
|
"shareFailure": "Chyba pri kopírovaní URL %{url} do schránky",
|
||||||
|
"downloadDialogTitle": "Stiahnuť %{resource} '%{name}' (%{size})",
|
||||||
|
"downloadOriginalFormat": "Stiahnuť v pôvodnom formáte"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"library": "Knižnica",
|
||||||
|
"librarySelector": {
|
||||||
|
"allLibraries": "Všetky knižnice (%{count})",
|
||||||
|
"multipleLibraries": "%{selected} z %{total} knižníc",
|
||||||
|
"selectLibraries": "Vyberte knižnice",
|
||||||
|
"none": "Žiadne"
|
||||||
|
},
|
||||||
|
"settings": "Nastavenia",
|
||||||
|
"version": "Verzia",
|
||||||
|
"theme": "Téma",
|
||||||
|
"personal": {
|
||||||
|
"name": "Osobné",
|
||||||
|
"options": {
|
||||||
|
"theme": "Téma",
|
||||||
|
"language": "Jazyk",
|
||||||
|
"defaultView": "Predvolená stránka",
|
||||||
|
"desktop_notifications": "Oznámenia na ploche",
|
||||||
|
"lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný",
|
||||||
|
"lastfmScrobbling": "Scrobblovať na Last.fm",
|
||||||
|
"listenBrainzScrobbling": "Scrobblovať na ListenBrainz",
|
||||||
|
"replaygain": "Mód ReplayGain",
|
||||||
|
"preAmp": "ReplayGain PreAmp (dB)",
|
||||||
|
"gain": {
|
||||||
|
"none": "Vypnuté",
|
||||||
|
"album": "Použiť Album Gain",
|
||||||
|
"track": "Použiť Track Gain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"albumList": "Albumy",
|
||||||
|
"playlists": "Zoznamy skladieb",
|
||||||
|
"sharedPlaylists": "Zdieľané zoznamy skladieb",
|
||||||
|
"about": "O Navidrome"
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"playListsText": "Rad",
|
||||||
|
"openText": "Otvoriť",
|
||||||
|
"closeText": "Zavrieť",
|
||||||
|
"notContentText": "Žiadne skladby",
|
||||||
|
"clickToPlayText": "Kliknite pre prehranie",
|
||||||
|
"clickToPauseText": "Kliknite pre pozastavenie",
|
||||||
|
"nextTrackText": "Ďalšia skladba",
|
||||||
|
"previousTrackText": "Predchádzajúca skladba",
|
||||||
|
"reloadText": "Znovu načítať",
|
||||||
|
"volumeText": "Hlasitosť",
|
||||||
|
"toggleLyricText": "Prepnúť text",
|
||||||
|
"toggleMiniModeText": "Zmenšiť",
|
||||||
|
"destroyText": "Zničiť",
|
||||||
|
"downloadText": "Stiahnuť",
|
||||||
|
"removeAudioListsText": "Vymazať zoznam",
|
||||||
|
"clickToDeleteText": "Kliknite pre odstránenie %{name}",
|
||||||
|
"emptyLyricText": "Bez textu",
|
||||||
|
"playModeText": {
|
||||||
|
"order": "Po poradí",
|
||||||
|
"orderLoop": "Opakovať",
|
||||||
|
"singleLoop": "Opakovať raz",
|
||||||
|
"shufflePlay": "Zamiešať"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"links": {
|
||||||
|
"homepage": "Domovská stránka",
|
||||||
|
"source": "Zdrojový kód",
|
||||||
|
"featureRequests": "Požiadavky na funkcie",
|
||||||
|
"lastInsightsCollection": "Posledný zber štatistík",
|
||||||
|
"insights": {
|
||||||
|
"disabled": "Zakázané",
|
||||||
|
"waiting": "Čakanie"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"about": "O aplikácii",
|
||||||
|
"config": "Konfigurácia"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"configName": "Názov konfigurácie",
|
||||||
|
"environmentVariable": "Premenná prostredia",
|
||||||
|
"currentValue": "Aktuálna hodnota",
|
||||||
|
"configurationFile": "Konfiguračný súbor",
|
||||||
|
"exportToml": "Exportovať konfiguráciu (TOML)",
|
||||||
|
"downloadToml": "Stiahnuť konfiguráciu (TOML)",
|
||||||
|
"exportSuccess": "Konfigurácia exportovaná do schránky vo formáte TOML",
|
||||||
|
"exportFailed": "Nepodarilo sa skopírovať konfiguráciu",
|
||||||
|
"devFlagsHeader": "Vývojové príznaky (môžu byť zmenené/odstránené)",
|
||||||
|
"devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"title": "Aktivita",
|
||||||
|
"totalScanned": "Naskenované priečinky",
|
||||||
|
"quickScan": "Rýchly sken",
|
||||||
|
"fullScan": "Úplný sken",
|
||||||
|
"selectiveScan": "Selektívne",
|
||||||
|
"serverUptime": "Doba od spustenia",
|
||||||
|
"serverDown": "OFFLINE",
|
||||||
|
"scanType": "Posledný Sken",
|
||||||
|
"status": "Chyba skenovania",
|
||||||
|
"elapsedTime": "Uplynutý čas"
|
||||||
|
},
|
||||||
|
"nowPlaying": {
|
||||||
|
"title": "Práve hrá",
|
||||||
|
"empty": "Nič sa neprehráva",
|
||||||
|
"minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"title": "Klávesové skratky Navidrome",
|
||||||
|
"hotkeys": {
|
||||||
|
"show_help": "Zobraziť túto nápovedu",
|
||||||
|
"toggle_menu": "Prepnúť bočné menu",
|
||||||
|
"toggle_play": "Prehrať / Pozastaviť",
|
||||||
|
"prev_song": "Predchádzajúca skladba",
|
||||||
|
"next_song": "Nasledujúca skladba",
|
||||||
|
"current_song": "Prejsť na aktuálnu skladbu",
|
||||||
|
"vol_up": "Zvýšiť hlasitosť",
|
||||||
|
"vol_down": "Znížiť hlasitosť",
|
||||||
|
"toggle_love": "Pridať túto skladbu do obľúbených"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,7 +38,7 @@
|
|||||||
"missing": "Saknade",
|
"missing": "Saknade",
|
||||||
"libraryName": "Bibliotek",
|
"libraryName": "Bibliotek",
|
||||||
"composer": "Kompositör",
|
"composer": "Kompositör",
|
||||||
"disc": ""
|
"disc": "Disc %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Lägg till i kön",
|
"addToQueue": "Lägg till i kön",
|
||||||
@ -355,7 +355,7 @@
|
|||||||
"selectedUsers": "Valda användare",
|
"selectedUsers": "Valda användare",
|
||||||
"allLibraries": "Tillåt alla bibliotek",
|
"allLibraries": "Tillåt alla bibliotek",
|
||||||
"selectedLibraries": "Valda bibliotek",
|
"selectedLibraries": "Valda bibliotek",
|
||||||
"allowWriteAccess": ""
|
"allowWriteAccess": "Tillåt skrivrättigheter"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@ -401,7 +401,7 @@
|
|||||||
"requiredHosts": "Krävda värdar",
|
"requiredHosts": "Krävda värdar",
|
||||||
"configValidationError": "Validering av konfigurationen misslyckades:",
|
"configValidationError": "Validering av konfigurationen misslyckades:",
|
||||||
"schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.",
|
"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": {
|
"placeholders": {
|
||||||
"configKey": "nyckel",
|
"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.",
|
"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",
|
"noSimilarSongsFound": "Hittade inga liknande låtar",
|
||||||
"noTopSongsFound": "Hittade inga topplå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": {
|
"menu": {
|
||||||
"library": "Bibliotek",
|
"library": "Bibliotek",
|
||||||
|
|||||||
@ -36,7 +36,9 @@
|
|||||||
"bitDepth": "Глибина розрядності",
|
"bitDepth": "Глибина розрядності",
|
||||||
"sampleRate": "Частота дискретизації",
|
"sampleRate": "Частота дискретизації",
|
||||||
"missing": "Поле відсутнє",
|
"missing": "Поле відсутнє",
|
||||||
"libraryName": "Бібліотека"
|
"libraryName": "Бібліотека",
|
||||||
|
"composer": "Композитор",
|
||||||
|
"disc": "Диск %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Прослухати пізніше",
|
"addToQueue": "Прослухати пізніше",
|
||||||
@ -46,7 +48,8 @@
|
|||||||
"download": "Завантажити",
|
"download": "Завантажити",
|
||||||
"playNext": "Наступна",
|
"playNext": "Наступна",
|
||||||
"info": "Отримати інформацію",
|
"info": "Отримати інформацію",
|
||||||
"showInPlaylist": "Показати у плейлісті"
|
"showInPlaylist": "Показати у плейлісті",
|
||||||
|
"instantMix": "Мікс"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"album": {
|
"album": {
|
||||||
@ -328,6 +331,82 @@
|
|||||||
"scanInProgress": "Сканування триває...",
|
"scanInProgress": "Сканування триває...",
|
||||||
"noLibrariesAssigned": "Немає бібліотек, призначених цьому користувачеві"
|
"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": {
|
"ra": {
|
||||||
@ -511,7 +590,14 @@
|
|||||||
"remove_all_missing_title": "Видалити всі відсутні файли",
|
"remove_all_missing_title": "Видалити всі відсутні файли",
|
||||||
"remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами.",
|
"remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами.",
|
||||||
"noSimilarSongsFound": "Не знайдено схожих треків",
|
"noSimilarSongsFound": "Не знайдено схожих треків",
|
||||||
"noTopSongsFound": "Не знайдено ТОП-треків"
|
"noTopSongsFound": "Не знайдено ТОП-треків",
|
||||||
|
"startingInstantMix": "Завантаження міксу...",
|
||||||
|
"uploadCover": "Завантажити обкладинку",
|
||||||
|
"removeCover": "Видалити обкладинку",
|
||||||
|
"coverUploaded": "Обкладинку оновлено",
|
||||||
|
"coverRemoved": "Обкладинка видалена",
|
||||||
|
"coverUploadError": "Помилка завантаження обкладинки",
|
||||||
|
"coverRemoveError": "Помилка видалення обкладинки"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"library": "Бібліотека",
|
"library": "Бібліотека",
|
||||||
@ -597,7 +683,8 @@
|
|||||||
"exportSuccess": "Конфігурацію експортовано в буфер обміну у форматі TOML",
|
"exportSuccess": "Конфігурацію експортовано в буфер обміну у форматі TOML",
|
||||||
"exportFailed": "Не вдалося скопіювати конфігурацію",
|
"exportFailed": "Не вдалося скопіювати конфігурацію",
|
||||||
"devFlagsHeader": "Прапорці розробки (можуть бути змінені/видалені)",
|
"devFlagsHeader": "Прапорці розробки (можуть бути змінені/видалені)",
|
||||||
"devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях."
|
"devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях.",
|
||||||
|
"downloadToml": "Завантажити конфігурацію (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
"missing": "遺失",
|
"missing": "遺失",
|
||||||
"libraryName": "媒體庫",
|
"libraryName": "媒體庫",
|
||||||
"composer": "作曲者",
|
"composer": "作曲者",
|
||||||
"disc": ""
|
"disc": "光碟 %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "加入至播放佇列",
|
"addToQueue": "加入至播放佇列",
|
||||||
|
|||||||
@ -81,7 +81,7 @@ main:
|
|||||||
albumsort:
|
albumsort:
|
||||||
aliases: [ tsoa, albumsort, soal, wm/albumsortorder ]
|
aliases: [ tsoa, albumsort, soal, wm/albumsortorder ]
|
||||||
albumversion:
|
albumversion:
|
||||||
aliases: [albumversion, musicbrainz_albumcomment, musicbrainz album comment, version]
|
aliases: [albumversion, musicbrainz_albumcomment, musicbrainz album comment]
|
||||||
album: true
|
album: true
|
||||||
genre:
|
genre:
|
||||||
aliases: [ tcon, genre, ©gen, wm/genre, ignr ]
|
aliases: [ tcon, genre, ©gen, wm/genre, ignr ]
|
||||||
|
|||||||
133
scheduler/crontab_schedule.go
Normal file
133
scheduler/crontab_schedule.go
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var parser = cron.NewParser(
|
||||||
|
cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseCrontab parses a cron expression with support for the crontab(5) random ~ syntax.
|
||||||
|
// Random values are resolved once at parse time. If no ~ is present, it delegates to
|
||||||
|
// robfig/cron's standard parser. Duration strings (e.g., "5m") are converted to "@every 5m".
|
||||||
|
func ParseCrontab(spec string) (cron.Schedule, error) {
|
||||||
|
if spec == "" {
|
||||||
|
return nil, fmt.Errorf("empty spec string")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := time.ParseDuration(spec); err == nil {
|
||||||
|
spec = "@every " + spec
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(spec, "~") {
|
||||||
|
return parser.Parse(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle TZ=/CRON_TZ= prefix
|
||||||
|
var tzPrefix string
|
||||||
|
if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") {
|
||||||
|
i := strings.Index(spec, " ")
|
||||||
|
if i == -1 {
|
||||||
|
return nil, fmt.Errorf("missing spec after timezone")
|
||||||
|
}
|
||||||
|
tzPrefix = spec[:i] + " "
|
||||||
|
spec = strings.TrimSpace(spec[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ descriptors cannot contain ~
|
||||||
|
if strings.HasPrefix(spec, "@") {
|
||||||
|
return nil, fmt.Errorf("random ~ syntax cannot be used with descriptors: %s", spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(spec)
|
||||||
|
fields, err := normalizeFields(fields)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve each ~ field to a concrete random value
|
||||||
|
for i, field := range fields {
|
||||||
|
if !strings.Contains(field, "~") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(field, ",/") {
|
||||||
|
return nil, fmt.Errorf("random ~ cannot be combined with lists or steps: %s", field)
|
||||||
|
}
|
||||||
|
v, parseErr := resolveRandomField(field, fieldBounds[i])
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, parseErr
|
||||||
|
}
|
||||||
|
fields[i] = strconv.FormatUint(uint64(v), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-assemble and parse with robfig
|
||||||
|
resolved := tzPrefix + strings.Join(fields, " ")
|
||||||
|
return parser.Parse(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
type bounds struct {
|
||||||
|
min, max uint
|
||||||
|
}
|
||||||
|
|
||||||
|
var fieldBounds = [6]bounds{
|
||||||
|
{0, 59}, // Second
|
||||||
|
{0, 59}, // Minute
|
||||||
|
{0, 23}, // Hour
|
||||||
|
{1, 31}, // Dom
|
||||||
|
{1, 12}, // Month
|
||||||
|
{0, 6}, // Dow
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRandomField parses a ~ field and returns a random value within the range.
|
||||||
|
func resolveRandomField(field string, b bounds) (uint, error) {
|
||||||
|
parts := strings.SplitN(field, "~", 2)
|
||||||
|
|
||||||
|
min := b.min
|
||||||
|
max := b.max
|
||||||
|
|
||||||
|
if parts[0] != "" {
|
||||||
|
v, err := strconv.ParseUint(parts[0], 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid random range start: %s", parts[0])
|
||||||
|
}
|
||||||
|
min = uint(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts[1] != "" {
|
||||||
|
v, err := strconv.ParseUint(parts[1], 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid random range end: %s", parts[1])
|
||||||
|
}
|
||||||
|
max = uint(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if min < b.min {
|
||||||
|
return 0, fmt.Errorf("random range start (%d) below minimum (%d): %s", min, b.min, field)
|
||||||
|
}
|
||||||
|
if max > b.max {
|
||||||
|
return 0, fmt.Errorf("random range end (%d) above maximum (%d): %s", max, b.max, field)
|
||||||
|
}
|
||||||
|
if min > max {
|
||||||
|
return 0, fmt.Errorf("random range start (%d) beyond end (%d): %s", min, max, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return min + uint(rand.IntN(int(max-min+1))), nil //nolint:gosec // Cryptographic randomness not needed for schedule jitter
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFields(fields []string) ([]string, error) {
|
||||||
|
switch len(fields) {
|
||||||
|
case 5:
|
||||||
|
return append([]string{"0"}, fields...), nil
|
||||||
|
case 6:
|
||||||
|
return fields, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("expected 5 or 6 fields, found %d: %v", len(fields), fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
194
scheduler/crontab_schedule_test.go
Normal file
194
scheduler/crontab_schedule_test.go
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("ParseCrontab", func() {
|
||||||
|
Describe("standard expressions", func() {
|
||||||
|
It("parses a 5-field expression", func() {
|
||||||
|
sched, err := ParseCrontab("5 * * * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(sched).To(BeAssignableToTypeOf(&cron.SpecSchedule{}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("parses a 6-field expression with seconds", func() {
|
||||||
|
sched, err := ParseCrontab("30 5 * * * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(sched).To(BeAssignableToTypeOf(&cron.SpecSchedule{}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("converts duration string to @every", func() {
|
||||||
|
sched, err := ParseCrontab("5m")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(sched).To(BeAssignableToTypeOf(cron.ConstantDelaySchedule{}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for empty string", func() {
|
||||||
|
_, err := ParseCrontab("")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("random ~ syntax", func() {
|
||||||
|
It("resolves A~B to a value within range", func() {
|
||||||
|
sched, err := ParseCrontab("0~30 * * * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
spec := sched.(*cron.SpecSchedule)
|
||||||
|
minute := findSetBit(spec.Minute)
|
||||||
|
Expect(minute).To(BeNumerically(">=", 0))
|
||||||
|
Expect(minute).To(BeNumerically("<=", 30))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves ~ alone to full field range", func() {
|
||||||
|
sched, err := ParseCrontab("~ * * * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
spec := sched.(*cron.SpecSchedule)
|
||||||
|
minute := findSetBit(spec.Minute)
|
||||||
|
Expect(minute).To(BeNumerically(">=", 0))
|
||||||
|
Expect(minute).To(BeNumerically("<=", 59))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves ~B as min~B", func() {
|
||||||
|
sched, err := ParseCrontab("~15 * * * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
spec := sched.(*cron.SpecSchedule)
|
||||||
|
minute := findSetBit(spec.Minute)
|
||||||
|
Expect(minute).To(BeNumerically(">=", 0))
|
||||||
|
Expect(minute).To(BeNumerically("<=", 15))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves A~ as A~max", func() {
|
||||||
|
sched, err := ParseCrontab("15~ * * * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
spec := sched.(*cron.SpecSchedule)
|
||||||
|
minute := findSetBit(spec.Minute)
|
||||||
|
Expect(minute).To(BeNumerically(">=", 15))
|
||||||
|
Expect(minute).To(BeNumerically("<=", 59))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves multiple random fields independently", func() {
|
||||||
|
sched, err := ParseCrontab("0~30 0~12 * * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
spec := sched.(*cron.SpecSchedule)
|
||||||
|
Expect(findSetBit(spec.Minute)).To(BeNumerically("<=", 30))
|
||||||
|
Expect(findSetBit(spec.Hour)).To(BeNumerically("<=", 12))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves ~ in DOM field with correct bounds", func() {
|
||||||
|
sched, err := ParseCrontab("0 0 ~ * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
spec := sched.(*cron.SpecSchedule)
|
||||||
|
dom := findSetBit(spec.Dom)
|
||||||
|
Expect(dom).To(BeNumerically(">=", 1))
|
||||||
|
Expect(dom).To(BeNumerically("<=", 31))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves ~ in month field with correct bounds", func() {
|
||||||
|
sched, err := ParseCrontab("0 0 1 ~ *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
spec := sched.(*cron.SpecSchedule)
|
||||||
|
month := findSetBit(spec.Month)
|
||||||
|
Expect(month).To(BeNumerically(">=", 1))
|
||||||
|
Expect(month).To(BeNumerically("<=", 12))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves ~ in DOW field with correct bounds", func() {
|
||||||
|
sched, err := ParseCrontab("0 0 * * ~")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
spec := sched.(*cron.SpecSchedule)
|
||||||
|
dow := findSetBit(spec.Dow)
|
||||||
|
Expect(dow).To(BeNumerically(">=", 0))
|
||||||
|
Expect(dow).To(BeNumerically("<=", 6))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("preserves TZ= prefix through resolution", func() {
|
||||||
|
sched, err := ParseCrontab("TZ=America/New_York 0~30 * * * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
spec := sched.(*cron.SpecSchedule)
|
||||||
|
nyc, _ := time.LoadLocation("America/New_York")
|
||||||
|
Expect(spec.Location).To(Equal(nyc))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("preserves non-random fields", func() {
|
||||||
|
sched, err := ParseCrontab("0~30 10 * * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
spec := sched.(*cron.SpecSchedule)
|
||||||
|
Expect(spec.Hour & (1 << 10)).ToNot(BeZero())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves to a stable value across repeated Next calls", func() {
|
||||||
|
sched, err := ParseCrontab("0~30 * * * *")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
ref := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
first := sched.Next(ref)
|
||||||
|
for range 50 {
|
||||||
|
Expect(sched.Next(ref)).To(Equal(first))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("error cases", func() {
|
||||||
|
It("rejects min > max", func() {
|
||||||
|
_, err := ParseCrontab("30~0 * * * *")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("beyond end"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects value above field maximum", func() {
|
||||||
|
_, err := ParseCrontab("0~60 * * * *")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("above maximum"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects value below field minimum", func() {
|
||||||
|
_, err := ParseCrontab("0 0 0~15 * *")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("below minimum"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects ~ mixed with comma (list)", func() {
|
||||||
|
_, err := ParseCrontab("0~30,45 * * * *")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("cannot be combined"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects ~ mixed with slash (step)", func() {
|
||||||
|
_, err := ParseCrontab("0~30/5 * * * *")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("cannot be combined"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects @ descriptor with ~", func() {
|
||||||
|
_, err := ParseCrontab("@every 0~30m")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("descriptor"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects wrong number of fields", func() {
|
||||||
|
_, err := ParseCrontab("0~30 * *")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects non-numeric range values", func() {
|
||||||
|
_, err := ParseCrontab("a~b * * * *")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// findSetBit returns the lowest bit position set in v, ignoring the starBit (bit 63).
|
||||||
|
func findSetBit(v uint64) int {
|
||||||
|
v &^= 1 << 63 // clear starBit
|
||||||
|
for i := 0; i < 63; i++ {
|
||||||
|
if v&(1<<uint(i)) != 0 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@ -33,10 +33,11 @@ func (s *scheduler) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *scheduler) Add(crontab string, cmd func()) (int, error) {
|
func (s *scheduler) Add(crontab string, cmd func()) (int, error) {
|
||||||
entryID, err := s.c.AddFunc(crontab, cmd)
|
schedule, err := ParseCrontab(crontab)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
entryID := s.c.Schedule(schedule, cron.FuncJob(cmd))
|
||||||
return int(entryID), nil
|
return int(entryID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,15 @@
|
|||||||
package scheduler
|
package scheduler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestScheduler(t *testing.T) {
|
func TestScheduler(t *testing.T) {
|
||||||
tests.Init(t, false)
|
|
||||||
log.SetLevel(log.LevelFatal)
|
log.SetLevel(log.LevelFatal)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Scheduler Suite")
|
RunSpecs(t, "Scheduler Suite")
|
||||||
@ -33,54 +29,22 @@ var _ = Describe("Scheduler", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("adds and executes a job", func() {
|
It("adds and executes a job", func() {
|
||||||
wg := sync.WaitGroup{}
|
done := make(chan struct{})
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
executed := false
|
id, err := s.Add("@every 50ms", func() {
|
||||||
id, err := s.Add("@every 100ms", func() {
|
close(done)
|
||||||
executed = true
|
|
||||||
wg.Done()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(id).ToNot(BeZero())
|
Expect(id).ToNot(BeZero())
|
||||||
|
|
||||||
wg.Wait()
|
Eventually(done).Should(BeClosed())
|
||||||
Expect(executed).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("removes a job", func() {
|
|
||||||
// Use a WaitGroup to ensure the job executes once
|
|
||||||
wg := sync.WaitGroup{}
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
counter := 0
|
|
||||||
id, err := s.Add("@every 100ms", func() {
|
|
||||||
counter++
|
|
||||||
if counter == 1 {
|
|
||||||
wg.Done() // Signal that the job has executed once
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("adds a job with random ~ syntax", func() {
|
||||||
|
id, err := s.Add("0~59 * * * *", func() {})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(id).ToNot(BeZero())
|
Expect(id).ToNot(BeZero())
|
||||||
|
|
||||||
// Wait for the job to execute at least once
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
// Verify job executed
|
|
||||||
Expect(counter).To(Equal(1))
|
|
||||||
|
|
||||||
// Remove the job
|
|
||||||
s.Remove(id)
|
s.Remove(id)
|
||||||
|
|
||||||
// Store the counter value
|
|
||||||
currentCount := counter
|
|
||||||
|
|
||||||
// Wait some time to ensure job doesn't execute again
|
|
||||||
time.Sleep(200 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify counter didn't increase
|
|
||||||
Expect(counter).To(Equal(currentCount))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
57
scripts/setup-worktree.sh
Executable file
57
scripts/setup-worktree.sh
Executable file
@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Setup a git worktree for Navidrome development.
|
||||||
|
# This script is called automatically by `make worktree` and by Claude Code's
|
||||||
|
# worktree isolation, but can also be run standalone:
|
||||||
|
#
|
||||||
|
# ./scripts/setup-worktree.sh <worktree-path> [--go-only]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --go-only Skip frontend (npm) setup. Useful for agents working only on Go code.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WORKTREE_PATH="${1:?Usage: $0 <worktree-path> [--go-only]}"
|
||||||
|
GO_ONLY="${2:-}"
|
||||||
|
|
||||||
|
# Resolve the main worktree root (where the original repo lives)
|
||||||
|
MAIN_WORKTREE="$(git -C "$WORKTREE_PATH" worktree list --porcelain | head -1 | sed 's/^worktree //')"
|
||||||
|
|
||||||
|
if [ ! -d "$WORKTREE_PATH" ]; then
|
||||||
|
echo "ERROR: Worktree path does not exist: $WORKTREE_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$WORKTREE_PATH"
|
||||||
|
|
||||||
|
echo "==> Setting up worktree at $WORKTREE_PATH"
|
||||||
|
|
||||||
|
# 1. Download Go dependencies
|
||||||
|
echo "==> Downloading Go dependencies..."
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
# 2. Install frontend dependencies (unless --go-only)
|
||||||
|
if [ "$GO_ONLY" != "--go-only" ]; then
|
||||||
|
echo "==> Installing frontend dependencies..."
|
||||||
|
(cd ui && npm ci --prefer-offline --no-audit 2>/dev/null || npm ci)
|
||||||
|
else
|
||||||
|
echo "==> Skipping frontend setup (--go-only)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Create required directories
|
||||||
|
mkdir -p data
|
||||||
|
|
||||||
|
# 4. Copy navidrome.toml from main worktree if it exists and not already present
|
||||||
|
if [ ! -f navidrome.toml ] && [ -f "$MAIN_WORKTREE/navidrome.toml" ]; then
|
||||||
|
echo "==> Copying navidrome.toml from main worktree..."
|
||||||
|
cp "$MAIN_WORKTREE/navidrome.toml" navidrome.toml
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Copy existing database from main worktree (already migrated and scanned)
|
||||||
|
# This is much faster than running migrations + a full scan from scratch.
|
||||||
|
if [ ! -f data/navidrome.db ] && [ -f "$MAIN_WORKTREE/data/navidrome.db" ]; then
|
||||||
|
echo "==> Copying database from main worktree (pre-migrated, pre-scanned)..."
|
||||||
|
cp "$MAIN_WORKTREE/data/navidrome.db" data/navidrome.db
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Worktree setup complete: $WORKTREE_PATH"
|
||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
"time"
|
"time"
|
||||||
@ -289,16 +290,26 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool
|
|||||||
type spyStreamer struct {
|
type spyStreamer struct {
|
||||||
LastRequest stream.Request
|
LastRequest stream.Request
|
||||||
LastMediaFile *model.MediaFile
|
LastMediaFile *model.MediaFile
|
||||||
|
SimulateError error // When set, NewStream returns this error
|
||||||
|
SimulateEmptyStream bool // When true, returns a 0-byte stream (simulates ffmpeg producing no output)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
|
func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
|
||||||
s.LastRequest = req
|
s.LastRequest = req
|
||||||
s.LastMediaFile = mf
|
s.LastMediaFile = mf
|
||||||
|
if s.SimulateError != nil {
|
||||||
|
return nil, s.SimulateError
|
||||||
|
}
|
||||||
format := req.Format
|
format := req.Format
|
||||||
if format == "" || format == "raw" {
|
if format == "" || format == "raw" {
|
||||||
format = mf.Suffix
|
format = mf.Suffix
|
||||||
}
|
}
|
||||||
return stream.NewTestStream(mf, format, req.BitRate), nil
|
content := "fake audio data"
|
||||||
|
if s.SimulateEmptyStream {
|
||||||
|
content = ""
|
||||||
|
}
|
||||||
|
r := io.NopCloser(strings.NewReader(content))
|
||||||
|
return stream.NewStream(mf, format, req.BitRate, r), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods.
|
// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods.
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -124,4 +127,56 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
|
|||||||
Expect(streamerSpy.LastRequest.Offset).To(Equal(30))
|
Expect(streamerSpy.LastRequest.Offset).To(Equal(30))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("stream creation failure", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'")
|
||||||
|
})
|
||||||
|
AfterEach(func() {
|
||||||
|
streamerSpy.SimulateError = nil
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns a Subsonic error for stream endpoint", func() {
|
||||||
|
w := doRawReq("stream", "id", flacTrackID, "format", "opus")
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK)) // Subsonic errors are returned as 200
|
||||||
|
|
||||||
|
var wrapper responses.JsonWrapper
|
||||||
|
Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
|
||||||
|
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
|
||||||
|
Expect(wrapper.Subsonic.Error).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns a Subsonic error for download endpoint", func() {
|
||||||
|
conf.Server.EnableDownloads = true
|
||||||
|
w := doRawReq("download", "id", flacTrackID, "format", "opus")
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var wrapper responses.JsonWrapper
|
||||||
|
Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
|
||||||
|
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
|
||||||
|
Expect(wrapper.Subsonic.Error).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("empty transcoded output", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
streamerSpy.SimulateEmptyStream = true
|
||||||
|
})
|
||||||
|
AfterEach(func() {
|
||||||
|
streamerSpy.SimulateEmptyStream = false
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 200 with empty body for stream endpoint", func() {
|
||||||
|
w := doRawReq("stream", "id", flacTrackID, "format", "opus")
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Body.Len()).To(Equal(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 200 with empty body for download endpoint", func() {
|
||||||
|
conf.Server.EnableDownloads = true
|
||||||
|
w := doRawReq("download", "id", flacTrackID, "format", "opus")
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Body.Len()).To(Equal(0))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -602,6 +603,36 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
|
|||||||
mf.UpdatedAt = originalUpdatedAt
|
mf.UpdatedAt = originalUpdatedAt
|
||||||
Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed())
|
Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("returns 500 when stream creation fails", func() {
|
||||||
|
// Get a valid decision token
|
||||||
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
token := resp.TranscodeDecision.TranscodeParams
|
||||||
|
Expect(token).ToNot(BeEmpty())
|
||||||
|
|
||||||
|
// Simulate streamer failure (e.g., ffmpeg missing codec)
|
||||||
|
streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'")
|
||||||
|
defer func() { streamerSpy.SimulateError = nil }()
|
||||||
|
|
||||||
|
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 500 when transcoded stream is empty", func() {
|
||||||
|
// Get a valid decision token
|
||||||
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
token := resp.TranscodeDecision.TranscodeParams
|
||||||
|
Expect(token).ToNot(BeEmpty())
|
||||||
|
|
||||||
|
// Simulate ffmpeg producing 0 bytes
|
||||||
|
streamerSpy.SimulateEmptyStream = true
|
||||||
|
defer func() { streamerSpy.SimulateEmptyStream = false }()
|
||||||
|
|
||||||
|
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("round-trip: decision then stream", func() {
|
Describe("round-trip: decision then stream", func() {
|
||||||
|
|||||||
@ -24,8 +24,8 @@ const maxImageSize = 10 << 20 // 10MB
|
|||||||
|
|
||||||
func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool {
|
func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool {
|
||||||
user, _ := request.UserFrom(r.Context())
|
user, _ := request.UserFrom(r.Context())
|
||||||
if !conf.Server.EnableCoverArtUpload && !user.IsAdmin {
|
if !conf.Server.EnableArtworkUpload && !user.IsAdmin {
|
||||||
http.Error(w, "cover art upload is disabled", http.StatusForbidden)
|
http.Error(w, "artwork upload is disabled", http.StatusForbidden)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@ -71,7 +71,7 @@ func (api *Router) routes() http.Handler {
|
|||||||
api.R(r, "/genre", model.Genre{}, false)
|
api.R(r, "/genre", model.Genre{}, false)
|
||||||
api.R(r, "/player", model.Player{}, true)
|
api.R(r, "/player", model.Player{}, true)
|
||||||
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||||
api.R(r, "/radio", model.Radio{}, true)
|
api.addRadioRoute(r)
|
||||||
api.R(r, "/tag", model.Tag{}, true)
|
api.R(r, "/tag", model.Tag{}, true)
|
||||||
if conf.Server.EnableSharing {
|
if conf.Server.EnableSharing {
|
||||||
api.RX(r, "/share", api.share.NewRepository, true)
|
api.RX(r, "/share", api.share.NewRepository, true)
|
||||||
|
|||||||
@ -28,8 +28,8 @@ var _ = Describe("Playlist Image Endpoints", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("uploadPlaylistImage guard",
|
DescribeTable("uploadPlaylistImage guard",
|
||||||
func(enableCoverArtUpload, isAdmin bool, expectedStatus int) {
|
func(enableArtworkUpload, isAdmin bool, expectedStatus int) {
|
||||||
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
|
conf.Server.EnableArtworkUpload = enableArtworkUpload
|
||||||
handler := uploadPlaylistImage(&mockPlaylistsService{})
|
handler := uploadPlaylistImage(&mockPlaylistsService{})
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil)
|
req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil)
|
||||||
@ -47,8 +47,8 @@ var _ = Describe("Playlist Image Endpoints", func() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
DescribeTable("deletePlaylistImage guard",
|
DescribeTable("deletePlaylistImage guard",
|
||||||
func(enableCoverArtUpload, isAdmin bool, expectedStatus int) {
|
func(enableArtworkUpload, isAdmin bool, expectedStatus int) {
|
||||||
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
|
conf.Server.EnableArtworkUpload = enableArtworkUpload
|
||||||
handler := deletePlaylistImage(&mockPlaylistsService{})
|
handler := deletePlaylistImage(&mockPlaylistsService{})
|
||||||
|
|
||||||
req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil)
|
req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil)
|
||||||
|
|||||||
70
server/nativeapi/radios.go
Normal file
70
server/nativeapi/radios.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package nativeapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/deluan/rest"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (api *Router) addRadioRoute(r chi.Router) {
|
||||||
|
constructor := func(ctx context.Context) rest.Repository {
|
||||||
|
return api.ds.Resource(ctx, model.Radio{})
|
||||||
|
}
|
||||||
|
r.Route("/radio", func(r chi.Router) {
|
||||||
|
r.Get("/", rest.GetAll(constructor))
|
||||||
|
r.Post("/", rest.Post(constructor))
|
||||||
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
|
r.Use(server.URLParamsMiddleware)
|
||||||
|
r.Get("/", rest.Get(constructor))
|
||||||
|
r.Put("/", rest.Put(constructor))
|
||||||
|
r.Delete("/", rest.Delete(constructor))
|
||||||
|
r.Post("/image", api.uploadRadioImage())
|
||||||
|
r.Delete("/image", api.deleteRadioImage())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) uploadRadioImage() http.HandlerFunc {
|
||||||
|
return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
|
||||||
|
radioID := chi.URLParamFromCtx(ctx, "id")
|
||||||
|
radio, err := api.ds.Radio(ctx).Get(radioID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
oldPath := radio.UploadedImagePath()
|
||||||
|
filename, err := api.imgUpload.SetImage(ctx, consts.EntityRadio, radio.ID, radio.Name, oldPath, reader, ext)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
radio.UploadedImage = filename
|
||||||
|
return api.ds.Radio(ctx).Put(radio, "UploadedImage")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) deleteRadioImage() http.HandlerFunc {
|
||||||
|
return handleImageDelete(func(ctx context.Context) error {
|
||||||
|
radioID := chi.URLParamFromCtx(ctx, "id")
|
||||||
|
radio, err := api.ds.Radio(ctx).Get(radioID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := api.imgUpload.RemoveImage(ctx, radio.UploadedImagePath()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
radio.UploadedImage = ""
|
||||||
|
return api.ds.Radio(ctx).Put(radio, "UploadedImage")
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/core/publicurl"
|
"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 {
|
func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share {
|
||||||
s.URL = ShareURL(r, s.ID)
|
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 {
|
for i := range s.Tracks {
|
||||||
s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID)
|
s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package public
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@ -54,34 +53,9 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
|
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
|
||||||
|
|
||||||
if stream.Seekable() {
|
n, err := stream.Serve(ctx, w, r)
|
||||||
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
|
if err != nil || n == 0 {
|
||||||
} else {
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
|
|
||||||
w.Header().Set("Accept-Ranges", "none")
|
|
||||||
w.Header().Set("Content-Type", stream.ContentType())
|
|
||||||
|
|
||||||
estimateContentLength := p.BoolOr("estimateContentLength", false)
|
|
||||||
|
|
||||||
// if Client requests the estimated content-length, send it
|
|
||||||
if estimateContentLength {
|
|
||||||
length := strconv.Itoa(stream.EstimatedContentLength())
|
|
||||||
log.Trace(ctx, "Estimated content-length", "contentLength", length)
|
|
||||||
w.Header().Set("Content-Length", length)
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
go func() { _, _ = io.Copy(io.Discard, stream) }()
|
|
||||||
} else {
|
|
||||||
c, err := io.Copy(w, stream)
|
|
||||||
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error sending shared transcoded file", "id", info.id, err)
|
|
||||||
} else {
|
|
||||||
log.Trace(ctx, "Success sending shared transcode file", "id", info.id, "size", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -55,13 +55,14 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
|||||||
"defaultLanguage": conf.Server.DefaultLanguage,
|
"defaultLanguage": conf.Server.DefaultLanguage,
|
||||||
"defaultUIVolume": conf.Server.DefaultUIVolume,
|
"defaultUIVolume": conf.Server.DefaultUIVolume,
|
||||||
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
|
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
|
||||||
|
"uiCoverArtSize": conf.Server.UICoverArtSize,
|
||||||
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
||||||
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
||||||
"gaTrackingId": conf.Server.GATrackingID,
|
"gaTrackingId": conf.Server.GATrackingID,
|
||||||
"losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")),
|
"losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")),
|
||||||
"devActivityPanel": conf.Server.DevActivityPanel,
|
"devActivityPanel": conf.Server.DevActivityPanel,
|
||||||
"enableUserEditing": conf.Server.EnableUserEditing,
|
"enableUserEditing": conf.Server.EnableUserEditing,
|
||||||
"enableCoverArtUpload": conf.Server.EnableCoverArtUpload,
|
"enableArtworkUpload": conf.Server.EnableArtworkUpload,
|
||||||
"enableSharing": conf.Server.EnableSharing,
|
"enableSharing": conf.Server.EnableSharing,
|
||||||
"shareURL": conf.Server.ShareURL,
|
"shareURL": conf.Server.ShareURL,
|
||||||
"defaultDownloadableShare": conf.Server.DefaultDownloadableShare,
|
"defaultDownloadableShare": conf.Server.DefaultDownloadableShare,
|
||||||
|
|||||||
@ -86,6 +86,7 @@ var _ = Describe("serveIndex", func() {
|
|||||||
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
|
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
|
||||||
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
|
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
|
||||||
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
|
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("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
||||||
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
||||||
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
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 {
|
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{}
|
pls := responses.OpenSubsonicPlaylist{}
|
||||||
|
|
||||||
if p.IsSmartPlaylist() {
|
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() {
|
Context("when no player in context", func() {
|
||||||
It("returns all fields", func() {
|
It("returns all fields", func() {
|
||||||
result := router.buildPlaylist(ctx, playlist)
|
result := router.buildPlaylist(ctx, playlist)
|
||||||
@ -213,6 +230,23 @@ var _ = Describe("buildPlaylist", func() {
|
|||||||
Expect(result.ValidUntil).To(Equal(&validUntil))
|
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())
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,11 @@ package subsonic
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
)
|
)
|
||||||
@ -66,6 +69,19 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro
|
|||||||
StreamUrl: g.StreamUrl,
|
StreamUrl: g.StreamUrl,
|
||||||
HomepageUrl: g.HomePageUrl,
|
HomepageUrl: g.HomePageUrl,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
player, _ := request.PlayerFrom(ctx)
|
||||||
|
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Add coverArt if not legacy client
|
||||||
|
var coverArt string
|
||||||
|
if g.UploadedImage != "" {
|
||||||
|
coverArt = g.CoverArtID().String()
|
||||||
|
}
|
||||||
|
res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{
|
||||||
|
CoverArt: coverArt,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response := newResponse()
|
response := newResponse()
|
||||||
@ -103,7 +119,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er
|
|||||||
Name: name,
|
Name: name,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = api.ds.Radio(ctx).Put(radio)
|
err = api.ds.Radio(ctx).Put(radio, "StreamUrl", "HomePageUrl", "Name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
146
server/subsonic/radio_test.go
Normal file
146
server/subsonic/radio_test.go
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Radio", func() {
|
||||||
|
var api *Router
|
||||||
|
var ds *tests.MockDataStore
|
||||||
|
var ctx context.Context
|
||||||
|
var radioRepo *tests.MockedRadioRepo
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ds = &tests.MockDataStore{}
|
||||||
|
auth.Init(ds)
|
||||||
|
api = &Router{ds: ds}
|
||||||
|
ctx = context.Background()
|
||||||
|
radioRepo = tests.CreateMockedRadioRepo()
|
||||||
|
ds.MockedRadio = radioRepo
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GetInternetRadios", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
radioRepo.All = model.Radios{
|
||||||
|
{ID: "rd-1", Name: "Radio 1", StreamUrl: "http://stream1.example.com", HomePageUrl: "http://home1.example.com", UploadedImage: "rd-1_cover.jpg"},
|
||||||
|
{ID: "rd-2", Name: "Radio 2", StreamUrl: "http://stream2.example.com"},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns all radios with basic fields", func() {
|
||||||
|
r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
response, err := api.GetInternetRadios(r)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(response.InternetRadioStations).ToNot(BeNil())
|
||||||
|
Expect(response.InternetRadioStations.Radios).To(HaveLen(2))
|
||||||
|
Expect(response.InternetRadioStations.Radios[0].ID).To(Equal("rd-1"))
|
||||||
|
Expect(response.InternetRadioStations.Radios[0].Name).To(Equal("Radio 1"))
|
||||||
|
Expect(response.InternetRadioStations.Radios[0].StreamUrl).To(Equal("http://stream1.example.com"))
|
||||||
|
Expect(response.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("http://home1.example.com"))
|
||||||
|
Expect(response.InternetRadioStations.Radios[1].ID).To(Equal("rd-2"))
|
||||||
|
Expect(response.InternetRadioStations.Radios[1].HomepageUrl).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("with a non-legacy client", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.Subsonic.LegacyClients = "legacy-client"
|
||||||
|
player := model.Player{Client: "modern-client"}
|
||||||
|
ctx = request.WithPlayer(ctx, player)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("includes coverArt from UploadedImage", func() {
|
||||||
|
r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
response, err := api.GetInternetRadios(r)
|
||||||
|
|
||||||
|
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("ra-rd-1_0"))
|
||||||
|
Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil())
|
||||||
|
Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("with a legacy client", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.Subsonic.LegacyClients = "legacy-client"
|
||||||
|
player := model.Player{Client: "legacy-client"}
|
||||||
|
ctx = request.WithPlayer(ctx, player)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not include coverArt", func() {
|
||||||
|
r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
response, err := api.GetInternetRadios(r)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(response.InternetRadioStations.Radios).To(HaveLen(2))
|
||||||
|
Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).To(BeNil())
|
||||||
|
Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).To(BeNil())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when no player in context", func() {
|
||||||
|
It("does not include coverArt (empty client matches legacy list)", func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.Subsonic.LegacyClients = "legacy-client"
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
response, err := api.GetInternetRadios(r)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).To(BeNil())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when legacy clients list is empty", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.Subsonic.LegacyClients = ""
|
||||||
|
player := model.Player{Client: "any-client"}
|
||||||
|
ctx = request.WithPlayer(ctx, player)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("includes coverArt for all clients", func() {
|
||||||
|
r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
response, err := api.GetInternetRadios(r)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil())
|
||||||
|
Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when repository fails", func() {
|
||||||
|
radioRepo.SetError(true)
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
_, err := api.GetInternetRadios(r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -8,6 +8,7 @@
|
|||||||
"id": "1",
|
"id": "1",
|
||||||
"name": "album",
|
"name": "album",
|
||||||
"artist": "artist",
|
"artist": "artist",
|
||||||
|
"duration": 292,
|
||||||
"genre": "rock",
|
"genre": "rock",
|
||||||
"userRating": 4,
|
"userRating": 4,
|
||||||
"genres": [
|
"genres": [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||||
<album id="1" name="album" artist="artist" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 & artist2" explicitStatus="clean" version="Deluxe Edition">
|
<album id="1" name="album" artist="artist" duration="292" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 & artist2" explicitStatus="clean" version="Deluxe Edition">
|
||||||
<genres name="rock"></genres>
|
<genres name="rock"></genres>
|
||||||
<genres name="progressive"></genres>
|
<genres name="progressive"></genres>
|
||||||
<discTitles disc="1" title="disc 1"></discTitles>
|
<discTitles disc="1" title="disc 1"></discTitles>
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
"openSubsonic": true,
|
"openSubsonic": true,
|
||||||
"album": {
|
"album": {
|
||||||
"id": "",
|
"id": "",
|
||||||
"name": ""
|
"name": "",
|
||||||
|
"duration": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||||
<album id="" name=""></album>
|
<album id="" name="" duration="0"></album>
|
||||||
</subsonic-response>
|
</subsonic-response>
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
"album": {
|
"album": {
|
||||||
"id": "",
|
"id": "",
|
||||||
"name": "",
|
"name": "",
|
||||||
|
"duration": 0,
|
||||||
"userRating": 0,
|
"userRating": 0,
|
||||||
"genres": [],
|
"genres": [],
|
||||||
"musicBrainzId": "",
|
"musicBrainzId": "",
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||||
<album id="" name=""></album>
|
<album id="" name="" duration="0"></album>
|
||||||
</subsonic-response>
|
</subsonic-response>
|
||||||
|
|||||||
@ -250,7 +250,7 @@ type AlbumID3 struct {
|
|||||||
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
|
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
|
||||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
||||||
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
||||||
Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"`
|
Duration int32 `xml:"duration,attr" json:"duration"`
|
||||||
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
|
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
|
||||||
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
|
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
|
||||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||||
@ -513,6 +513,11 @@ type Radio struct {
|
|||||||
Name string `xml:"name,attr" json:"name"`
|
Name string `xml:"name,attr" json:"name"`
|
||||||
StreamUrl string `xml:"streamUrl,attr" json:"streamUrl"`
|
StreamUrl string `xml:"streamUrl,attr" json:"streamUrl"`
|
||||||
HomepageUrl string `xml:"homePageUrl,omitempty,attr" json:"homePageUrl,omitempty"`
|
HomepageUrl string `xml:"homePageUrl,omitempty,attr" json:"homePageUrl,omitempty"`
|
||||||
|
*OpenSubsonicRadio `xml:",omitempty" json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenSubsonicRadio struct {
|
||||||
|
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type JukeboxStatus struct {
|
type JukeboxStatus struct {
|
||||||
|
|||||||
@ -288,7 +288,7 @@ var _ = Describe("Responses", func() {
|
|||||||
Context("with data", func() {
|
Context("with data", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
album := AlbumID3{
|
album := AlbumID3{
|
||||||
Id: "1", Name: "album", Artist: "artist", Genre: "rock",
|
Id: "1", Name: "album", Artist: "artist", Duration: 292, Genre: "rock",
|
||||||
}
|
}
|
||||||
album.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{
|
album.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{
|
||||||
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
package subsonic
|
package subsonic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
@ -17,38 +14,6 @@ import (
|
|||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *stream.Stream, id string) {
|
|
||||||
if stream.Seekable() {
|
|
||||||
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
|
|
||||||
} else {
|
|
||||||
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
|
|
||||||
w.Header().Set("Accept-Ranges", "none")
|
|
||||||
w.Header().Set("Content-Type", stream.ContentType())
|
|
||||||
|
|
||||||
estimateContentLength := req.Params(r).BoolOr("estimateContentLength", false)
|
|
||||||
|
|
||||||
// if Client requests the estimated content-length, send it
|
|
||||||
if estimateContentLength {
|
|
||||||
length := strconv.Itoa(stream.EstimatedContentLength())
|
|
||||||
log.Trace(ctx, "Estimated content-length", "contentLength", length)
|
|
||||||
w.Header().Set("Content-Length", length)
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
go func() { _, _ = io.Copy(io.Discard, stream) }()
|
|
||||||
} else {
|
|
||||||
c, err := io.Copy(w, stream)
|
|
||||||
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error sending transcoded file", "id", id, err)
|
|
||||||
} else {
|
|
||||||
log.Trace(ctx, "Success sending transcode file", "id", id, "size", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
p := req.Params(r)
|
p := req.Params(r)
|
||||||
@ -81,9 +46,8 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su
|
|||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
|
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
|
||||||
|
|
||||||
api.serveStream(ctx, w, r, stream, id)
|
_, err = stream.Serve(ctx, w, r)
|
||||||
|
return nil, err
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||||
@ -151,20 +115,18 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
|
|||||||
disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name())
|
disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name())
|
||||||
w.Header().Set("Content-Disposition", disposition)
|
w.Header().Set("Content-Disposition", disposition)
|
||||||
|
|
||||||
api.serveStream(ctx, w, r, stream, id)
|
_, err = stream.Serve(ctx, w, r)
|
||||||
return nil, nil
|
return nil, err
|
||||||
case *model.Album:
|
case *model.Album:
|
||||||
setHeaders(v.Name)
|
setHeaders(v.Name)
|
||||||
err = api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)
|
return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)
|
||||||
case *model.Artist:
|
case *model.Artist:
|
||||||
setHeaders(v.Name)
|
setHeaders(v.Name)
|
||||||
err = api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)
|
return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)
|
||||||
case *model.Playlist:
|
case *model.Playlist:
|
||||||
setHeaders(v.Name)
|
setHeaders(v.Name)
|
||||||
err = api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)
|
return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)
|
||||||
default:
|
default:
|
||||||
err = model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -395,7 +395,9 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*
|
|||||||
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
api.serveStream(ctx, w, r, stream, mediaID)
|
n, err := stream.Serve(ctx, w, r)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user