Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
906086b929
chore(deps-dev): bump @vitejs/plugin-react from 5.1.2 to 6.0.1 in /ui
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.1.2 to 6.0.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-17 16:30:45 +00:00
126 changed files with 1051 additions and 6963 deletions

View File

@ -338,7 +338,7 @@ jobs:
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to Docker Hub
uses: nick-fields/retry@v4
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3

3
.gitignore vendored
View File

@ -37,5 +37,4 @@ AGENTS.md
*.wasm
*.ndp
openspec/
go.work*
.worktrees/
go.work*

View File

@ -55,7 +55,6 @@ linters:
- third_party$
- builtin$
- examples$
- node_modules
formatters:
exclusions:
generated: lax
@ -63,4 +62,3 @@ formatters:
- third_party$
- builtin$
- examples$
- node_modules

View File

@ -1,8 +1,6 @@
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
comma:=,
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
GO_BUILD_TAGS=netgo,sqlite_fts5
# Set global environment variables, required for most targets
export CGO_CFLAGS_ALLOW=--define-prefix
@ -235,39 +233,6 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
.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

View File

@ -58,20 +58,7 @@ func (e extractor) Version() string {
return "unknown"
}
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)
}
}()
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
f, close, err := e.openFile(filePath)
if err != nil {
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
@ -125,6 +112,16 @@ func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err er
// openFile opens the file at filePath using the extractor's filesystem.
// 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) {
// 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
file, err := e.fs.Open(filePath)
if err != nil {

View File

@ -248,7 +248,6 @@ ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
TimeoutStopSec=20
RestartSec=120
EnvironmentFile=-/etc/sysconfig/{{.Name}}
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
DevicePolicy=closed
NoNewPrivileges=yes

View File

@ -16,8 +16,8 @@ import (
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/scheduler"
"github.com/navidrome/navidrome/utils/run"
"github.com/robfig/cron/v3"
"github.com/spf13/viper"
)
@ -70,7 +70,6 @@ type configOptions struct {
MPVCmdTemplate string
CoverArtPriority string
CoverArtQuality int
EnableWebPEncoding bool
ArtistArtPriority string
ArtistImageFolder string
DiscArtPriority string
@ -79,7 +78,7 @@ type configOptions struct {
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableArtworkUpload bool
EnableCoverArtUpload bool
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
@ -88,7 +87,6 @@ type configOptions struct {
DefaultLanguage string
DefaultUIVolume int
UISearchDebounceMs int
UICoverArtSize int
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
@ -143,6 +141,7 @@ type configOptions struct {
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
DevEnableMediaFileProbe bool
DevJpegCoverArt bool
}
type scannerOptions struct {
@ -259,13 +258,6 @@ type searchOptions struct {
FullString bool
}
// logFatal prints a fatal error message to stderr and exits.
// Overridden in tests to allow testing fatal paths.
var logFatal = func(args ...any) {
_, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...)
os.Exit(1)
}
var (
Server = &configOptions{}
hooks []func()
@ -275,14 +267,14 @@ func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
if err != nil {
logFatal("Error reading config file:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
os.Exit(1)
}
Load(true)
}
func Load(noConfigDump bool) {
parseIniFileConfiguration()
remapEnvVarKeysFromConfig()
// Map deprecated options to their new names for backwards compatibility
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
@ -292,12 +284,14 @@ func Load(noConfigDump bool) {
err := viper.Unmarshal(&Server)
if err != nil {
logFatal("Error parsing config:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil {
logFatal("Error creating data path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
os.Exit(1)
}
if Server.CacheFolder == "" {
@ -305,12 +299,14 @@ func Load(noConfigDump bool) {
}
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
if err != nil {
logFatal("Error creating cache path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
os.Exit(1)
}
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
if err != nil {
logFatal("Error creating artwork path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err)
os.Exit(1)
}
if Server.Plugins.Enabled {
@ -319,7 +315,8 @@ func Load(noConfigDump bool) {
}
err = os.MkdirAll(Server.Plugins.Folder, 0700)
if err != nil {
logFatal("Error creating plugins path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
os.Exit(1)
}
}
@ -331,7 +328,8 @@ func Load(noConfigDump bool) {
if Server.Backup.Path != "" {
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
if err != nil {
logFatal("Error creating backup path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
os.Exit(1)
}
}
@ -339,14 +337,13 @@ func Load(noConfigDump bool) {
if Server.LogFile != "" {
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
os.Exit(1)
}
log.SetOutput(out)
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
} else if os.Getenv("JOURNAL_STREAM") != "" {
// When running under systemd, prepend syslog priority prefixes so
// 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()
}
@ -371,7 +368,8 @@ func Load(noConfigDump bool) {
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
logFatal("Invalid BaseURL:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
os.Exit(1)
}
Server.BasePath = u.Path
u.Path = ""
@ -425,13 +423,6 @@ func Load(noConfigDump bool) {
// Removed options
logRemovedOptions("Spotify.ID", "Spotify.Secret")
// Validate other options
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
newValue := max(200, min(1200, Server.UICoverArtSize))
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
Server.UICoverArtSize = newValue
}
// Call init hooks
for _, hook := range hooks {
hook()
@ -473,35 +464,6 @@ func logRemovedOptions(options ...string) {
}
}
// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly
// using environment variable names) and remaps them to canonical Viper keys with a warning.
func remapEnvVarKeysFromConfig() {
for _, key := range viper.AllKeys() {
if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) {
continue
}
stripped := strings.TrimPrefix(key, "nd_")
canonicalKey := strings.ReplaceAll(stripped, "_", ".")
displayNDKey := "ND_" + strings.ToUpper(stripped)
displayCanonical := toPascalCase(canonicalKey)
if viper.InConfig(canonicalKey) {
logFatal(fmt.Sprintf(
"Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+
"The 'ND_' prefix is only needed for environment variables, not config file keys.",
displayNDKey, displayCanonical,
))
return
}
viper.Set(canonicalKey, viper.Get(key))
_, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+
"The 'ND_' prefix is only needed for environment variables.\n",
displayNDKey, displayCanonical,
)
}
}
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
// the config has been read by viper, but before unmarshalling it into the Config struct.
func mapDeprecatedOption(legacyName, newName string) {
@ -519,15 +481,18 @@ func parseIniFileConfiguration() {
var iniConfig map[string]any
err := viper.Unmarshal(&iniConfig)
if err != nil {
logFatal("Error parsing config:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
cfg, ok := iniConfig["default"].(map[string]any)
if !ok {
logFatal("Error parsing config: missing [default] section:", iniConfig)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
os.Exit(1)
}
err = viper.MergeConfigMap(cfg)
if err != nil {
logFatal("Error parsing config:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
}
}
@ -605,9 +570,15 @@ func validateBackupSchedule() error {
}
func validateSchedule(schedule, field string) (string, error) {
_, err := scheduler.ParseCrontab(schedule)
if _, err := time.ParseDuration(schedule); err == nil {
schedule = "@every " + schedule
}
c := cron.New()
id, err := c.AddFunc(schedule, func() {})
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)
} else {
c.Remove(id)
}
return schedule, err
}
@ -650,21 +621,6 @@ func normalizeSearchBackend(value string) string {
}
}
// toPascalCase converts a dotted lowercase config key to PascalCase for display.
// Example: "scanner.schedule" → "Scanner.Schedule"
func toPascalCase(key string) string {
if key == "" {
return ""
}
parts := strings.Split(key, ".")
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
return strings.Join(parts, ".")
}
// AddHook is used to register initialization code that should run as soon as the config is loaded
func AddHook(hook func()) {
hooks = append(hooks, hook)
@ -720,13 +676,10 @@ func setViperDefaults() {
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("ffmpegpath", "")
viper.SetDefault("mpvpath", "")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverartquality", 75)
viper.SetDefault("enablewebpencoding", false)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
viper.SetDefault("artistimagefolder", "")
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
viper.SetDefault("enablegravatar", false)
@ -737,11 +690,10 @@ func setViperDefaults() {
viper.SetDefault("defaultlanguage", "")
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("enablecoverartupload", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
@ -806,7 +758,6 @@ func setViperDefaults() {
viper.SetDefault("plugins.enabled", true)
viper.SetDefault("plugins.cachesize", "200MB")
viper.SetDefault("plugins.autoreload", false)
viper.SetDefault("plugins.loglevel", "")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
@ -820,7 +771,7 @@ func setViperDefaults() {
viper.SetDefault("devuishowconfig", true)
viper.SetDefault("devneweventstream", true)
viper.SetDefault("devoffsetoptimize", 50000)
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU()))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
@ -836,6 +787,7 @@ func setViperDefaults() {
viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
viper.SetDefault("devenablemediafileprobe", true)
viper.SetDefault("devjpegcoverart", false)
}
func init() {
@ -872,7 +824,8 @@ func InitConfig(cfgFile string, loadEnvVars bool) {
err := viper.ReadInConfig()
if viper.ConfigFileUsed() != "" && err != nil {
logFatal("Navidrome could not open config file:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
os.Exit(1)
}
}

View File

@ -2,7 +2,6 @@ package conf_test
import (
"fmt"
"os"
"path/filepath"
"testing"
@ -25,11 +24,6 @@ var _ = Describe("Configuration", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
// Panic instead of exiting on fatal errors to allow testing error conditions
DeferCleanup(conf.SetLogFatal(func(args ...any) {
panic(fmt.Sprint(args...))
}))
})
Describe("ParseLanguages", func() {
@ -114,111 +108,6 @@ var _ = Describe("Configuration", func() {
Entry("falls back to 'fts' for empty string", "", "fts"),
)
DescribeTable("ToPascalCase",
func(input, expected string) {
Expect(conf.ToPascalCase(input)).To(Equal(expected))
},
Entry("simple key", "address", "Address"),
Entry("dotted key", "scanner.schedule", "Scanner.Schedule"),
Entry("already capitalized", "Address", "Address"),
Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"),
Entry("empty string", "", ""),
)
Describe("remapEnvVarKeysFromConfig", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
It("remaps ND_-prefixed keys to canonical keys", func() {
filename := filepath.Join("testdata", "cfg_nd_keys.toml")
conf.InitConfig(filename, false)
conf.Load(true)
Expect(conf.Server.Address).To(Equal("127.0.0.1"))
Expect(conf.Server.Port).To(Equal(4531))
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
})
It("exits with fatal error when both ND_ and canonical key exist", func() {
filename := filepath.Join("testdata", "cfg_nd_conflict.toml")
conf.InitConfig(filename, false)
Expect(func() { conf.Load(true) }).To(PanicWith(And(
ContainSubstring("ND_ADDRESS"),
ContainSubstring("Address"),
ContainSubstring("only needed for environment variables"),
)))
})
It("does nothing when no ND_ keys are present", func() {
filename := filepath.Join("testdata", "cfg.toml")
conf.InitConfig(filename, false)
conf.Load(true)
// Verify normal config loading still works
Expect(conf.Server.MusicFolder).To(Equal("/toml/music"))
})
})
Describe("logFatal", func() {
var invalidPath string
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("loglevel", "error")
conf.ResetConf()
// Create a file so that any path under it is invalid on all OSes
f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker")
Expect(err).ToNot(HaveOccurred())
f.Close()
invalidPath = filepath.Join(f.Name(), "subdir")
})
It("is called when LoadFromFile gets an invalid config file", func() {
Expect(func() {
conf.LoadFromFile(filepath.Join(invalidPath, "file.toml"))
}).To(PanicWith(ContainSubstring("Error reading config file")))
})
It("is called when DataFolder is not writable", func() {
viper.SetDefault("datafolder", invalidPath)
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating data path")))
})
It("is called when CacheFolder is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("cachefolder", invalidPath)
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating cache path")))
})
It("is called when LogFile path is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error opening log file")))
})
It("is called when BaseURL is invalid", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("baseurl", "://invalid")
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Invalid BaseURL")))
})
})
DescribeTable("should load configuration from",
func(format string) {
filename := filepath.Join("testdata", "cfg."+format)

View File

@ -11,11 +11,3 @@ var ParseLanguages = parseLanguages
var ValidateURL = validateURL
var NormalizeSearchBackend = normalizeSearchBackend
var ToPascalCase = toPascalCase
func SetLogFatal(f func(...any)) func() {
old := logFatal
logFatal = f
return func() { logFatal = old }
}

View File

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

View File

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

View File

@ -70,6 +70,7 @@ const (
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
@ -84,10 +85,6 @@ const (
Zwsp = string('\u200b')
)
const (
DefaultUICoverArtSize = 300
)
// Prometheus options
const (
PrometheusDefaultPath = "/metrics"
@ -110,7 +107,6 @@ const (
const (
EntityArtist = "artist"
EntityPlaylist = "playlist"
EntityRadio = "radio"
)
const (

View File

@ -124,8 +124,6 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
case model.KindDiscArtwork:
artReader, err = newDiscArtworkReader(ctx, a, artID)
case model.KindRadioArtwork:
artReader, err = newRadioArtworkReader(ctx, a, artID)
default:
return nil, ErrUnavailable
}

View File

@ -380,24 +380,24 @@ var _ = Describe("Artwork", func() {
})
})
When("Square is false", func() {
It("returns PNG if original image is a PNG", func() {
It("returns WebP even if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(format).To(Equal("webp"))
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
It("returns JPEG if original image is not a PNG", func() {
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("jpeg"))
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))
@ -430,51 +430,24 @@ var _ = Describe("Artwork", func() {
Expect(img.Bounds().Size().X).To(Equal(size))
Expect(img.Bounds().Size().Y).To(Equal(size))
},
Entry("portrait png image", "png", "png", false, 200),
Entry("landscape png image", "png", "png", true, 200),
Entry("portrait jpg image", "jpg", "png", false, 200),
Entry("landscape jpg image", "jpg", "png", true, 200),
Entry("portrait png image", "png", "webp", false, 200),
Entry("landscape png image", "png", "webp", true, 200),
Entry("portrait jpg image", "jpg", "webp", false, 200),
Entry("landscape jpg image", "jpg", "webp", true, 200),
)
})
When("EnableWebPEncoding is true and square is false", func() {
When("DevJpegCoverArt is true and square is false", func() {
BeforeEach(func() {
conf.Server.EnableWebPEncoding = true
conf.Server.DevJpegCoverArt = true
})
It("returns WebP even if original image is a PNG", func() {
It("returns JPEG even if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("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(format).To(Equal("jpeg"))
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
@ -490,11 +463,11 @@ var _ = Describe("Artwork", func() {
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
When("EnableWebPEncoding is false and square is true", func() {
When("DevJpegCoverArt is true and square is true", func() {
var alCover model.Album
BeforeEach(func() {
conf.Server.EnableWebPEncoding = false
conf.Server.DevJpegCoverArt = true
})
It("returns PNG for square mode", func() {
dirName := createImage("png", false, 200)

View File

@ -10,6 +10,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -23,7 +24,7 @@ type CacheWarmer interface {
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background
// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original
// image size, as well as the size defined by the UICoverArtSize config option.
// image size, as well as the size defined in the UICoverArtSize constant.
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
// If image cache is disabled, return a NOOP implementation
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
@ -37,11 +38,10 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
}
a := &cacheWarmer{
artwork: artwork,
cache: cache,
buffer: make(map[model.ArtworkID]struct{}),
wakeSignal: make(chan struct{}, 1),
coverArtSize: conf.Server.UICoverArtSize,
artwork: artwork,
cache: cache,
buffer: make(map[model.ArtworkID]struct{}),
wakeSignal: make(chan struct{}, 1),
}
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
@ -51,12 +51,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
}
type cacheWarmer struct {
artwork Artwork
buffer map[model.ArtworkID]struct{}
mutex sync.Mutex
cache cache.FileCache
wakeSignal chan struct{}
coverArtSize int
artwork Artwork
buffer map[model.ArtworkID]struct{}
mutex sync.Mutex
cache cache.FileCache
wakeSignal chan struct{}
}
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
@ -143,14 +142,16 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
size := a.coverArtSize
r, _, err := a.artwork.Get(ctx, id, size, true)
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
if err != nil {
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
return fmt.Errorf("caching id='%s': %w", id, err)
}
defer r.Close()
_, err = io.Copy(io.Discard, r)
r.Close()
return err
if err != nil {
return err
}
return nil
}
func NoopCacheWarmer() CacheWarmer {

View File

@ -6,7 +6,6 @@ import (
"fmt"
"io"
"strings"
"sync"
"sync/atomic"
"time"
@ -174,42 +173,20 @@ var _ = Describe("CacheWarmer", func() {
return len(cw.buffer)
}).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 {
err error
mu sync.Mutex
cachedSizes []int
err error
}
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
if m.err != nil {
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
}
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) {
return m.Get(ctx, model.ArtworkID{}, size, square)
}

View File

@ -61,7 +61,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
func (a *albumArtworkReader) Key() string {
hashInput := conf.Server.CoverArtPriority
if conf.Server.EnableExternalServices {
hashInput = conf.Server.Agents + hashInput
hashInput += conf.Server.Agents
}
hash := md5.Sum([]byte(hashInput))
return fmt.Sprintf(

View File

@ -264,6 +264,6 @@ func fillCenter(src image.Image, dstW, dstH int) image.Image {
}
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil)
xdraw.BiLinear.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil)
return dst
}

View File

@ -1,40 +0,0 @@
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())
}

View File

@ -1,84 +0,0 @@
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())
})
})
})
})

View File

@ -19,16 +19,6 @@ import (
xdraw "golang.org/x/image/draw"
)
func init() {
conf.AddHook(func() {
if err := webp.Dynamic(); err != nil {
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
} else {
log.Debug("Using native libwebp for WebP encoding/decoding")
}
})
}
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
@ -108,26 +98,28 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader
return nil, 0, fmt.Errorf("reading image data: %w", err)
}
// Preserve animation for animated images
if isAnimatedGIF(data) {
if a.a.ffmpeg.IsAvailable() {
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality)
if err == nil {
return r, 0, nil
// Preserve animation for animated images (skip for square thumbnails)
if !a.square {
if isAnimatedGIF(data) {
if a.a.ffmpeg.IsAvailable() {
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality)
if err == nil {
return r, 0, nil
}
log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
}
log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
return bytes.NewReader(data), 0, nil
}
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
return bytes.NewReader(data), 0, nil
}
return resizeStaticImage(data, a.size, a.square)
}
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
original, format, err := image.Decode(bytes.NewReader(data))
original, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, 0, err
}
@ -163,16 +155,18 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
dstRect = dst.Bounds()
}
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
xdraw.BiLinear.Scale(dst, dstRect, original, bounds, draw.Src, nil)
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
if conf.Server.EnableWebPEncoding {
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
} else if format == "png" || square {
err = png.Encode(buf, dst)
if conf.Server.DevJpegCoverArt {
if square {
err = png.Encode(buf, dst)
} else {
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
}
} else {
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
}
if err != nil {
bufPool.Put(buf)

View File

@ -54,17 +54,17 @@ var _ = Describe("resizeImage", func() {
Expect(len(output)).To(BeNumerically(">", 0))
})
It("preserves animation for square thumbnails with animated GIF", func() {
It("skips animation for square thumbnails even with animated GIF", func() {
r.square = true
data := createAnimatedGIF(3)
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
// Should have been processed by ffmpeg (mock returns input data)
output, err := io.ReadAll(result)
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal(data))
// Should fall through to static resize (not ffmpeg conversion)
// The minimal test GIF may or may not resize successfully,
// but ffmpeg should NOT have been called for animated conversion
_ = result
_ = err
// Verify by checking the mock wasn't used for animated conversion:
// If ffmpeg was called, it would return mock data, not static resize result
})
})
@ -81,17 +81,13 @@ var _ = Describe("resizeImage", func() {
Expect(output).To(Equal(data))
})
It("preserves animated WebP for square thumbnails", func() {
It("does not passthrough animated WebP for square thumbnails", func() {
r.square = true
data := createAnimatedWebPBytes()
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
// Should return original data unchanged
output, err := io.ReadAll(result)
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal(data))
// Should fall through to static resize, which will fail on fake WebP data
_, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
// Static decode will fail on our minimal test WebP bytes (not a real image)
Expect(err).To(HaveOccurred())
})
})
@ -108,17 +104,15 @@ var _ = Describe("resizeImage", func() {
Expect(output).To(Equal(data))
})
It("preserves animated PNG for square thumbnails", func() {
It("does not passthrough animated PNG for square thumbnails", func() {
r.square = true
data := createAPNGBytes()
// Should fall through to static resize
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
// Should return original data unchanged
output, err := io.ReadAll(result)
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal(data))
// Static PNG decode should succeed on our APNG (it's a valid PNG)
if err == nil {
Expect(result).ToNot(BeNil())
}
})
})

View File

@ -130,25 +130,10 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
if err != nil {
return nil, "", err
}
// 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
return r, path, nil
}
}
// readCloser combines a Reader and a Closer into an io.ReadCloser.
type readCloser struct {
io.Reader
io.Closer
}
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
return func() (io.ReadCloser, string, error) {
r, _, err := a.Get(ctx, id, 0, false)

View File

@ -374,6 +374,8 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error)
return nil, err
}
// Use already-stored image URL if available, avoiding expensive external API calls.
// If the info is expired, the background refresh (via UpdateArtistInfo/artistQueue) will update it.
imageUrl := artist.ArtistImageUrl()
if imageUrl == "" {
// No cached URL — must fetch from external source synchronously
@ -383,14 +385,6 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error)
return nil, ctx.Err()
}
imageUrl = artist.ArtistImageUrl()
} else {
// If cached info is expired, enqueue a background refresh so that config changes
// (e.g. disabling an agent) take effect without waiting for a full artist info refresh.
updatedAt := V(artist.ExternalInfoUpdatedAt)
if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt)
e.artistQueue.enqueue(&artist)
}
}
if imageUrl == "" {

View File

@ -1,17 +1,14 @@
package external_test
import (
"bytes"
"context"
"errors"
"net/url"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -269,68 +266,6 @@ var _ = Describe("Provider - ArtistImage", func() {
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
})
It("returns cached URL and does not call agent when info is not expired", func() {
// Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt
recentTime := time.Now().Add(-1 * time.Minute)
cachedArtist := &model.Artist{
ID: "artist-cached",
Name: "Cached Artist",
LargeImageUrl: "http://example.com/cached-large.jpg",
ExternalInfoUpdatedAt: &recentTime,
}
mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe()
expectedURL, _ := url.Parse("http://example.com/cached-large.jpg")
// Capture log output
var logBuf bytes.Buffer
log.SetOutput(&logBuf)
defer log.SetOutput(GinkgoWriter)
log.SetLevel(log.LevelDebug)
// Act
imgURL, err := provider.ArtistImage(ctx, "artist-cached")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything)
// Assert: background refresh was NOT enqueued
Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh"))
})
It("returns stale URL and enqueues refresh when info is expired", func() {
// Arrange
conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond
expiredTime := time.Now().Add(-1 * time.Hour)
staleArtist := &model.Artist{
ID: "artist-expired",
Name: "Expired Artist",
LargeImageUrl: "http://example.com/expired-large.jpg",
ExternalInfoUpdatedAt: &expiredTime,
}
mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe()
expectedURL, _ := url.Parse("http://example.com/expired-large.jpg")
// Capture log output
var logBuf bytes.Buffer
log.SetOutput(&logBuf)
defer log.SetOutput(GinkgoWriter)
log.SetLevel(log.LevelDebug)
// Act
imgURL, err := provider.ArtistImage(ctx, "artist-expired")
// Assert: returns stale URL immediately, no agent call
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything)
// Assert: background refresh was enqueued
Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh"))
})
Context("Unicode handling in artist names", func() {
var artistWithEnDash *model.Artist
var expectedURL *url.URL

View File

@ -1,7 +1,6 @@
package ffmpeg
import (
"bytes"
"context"
"encoding/json"
"errors"
@ -259,11 +258,10 @@ func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (
type ffCmd struct {
*io.PipeReader
out *io.PipeWriter
args []string
cmd *exec.Cmd
input io.Reader // optional stdin source
stderr *bytes.Buffer
out *io.PipeWriter
args []string
cmd *exec.Cmd
input io.Reader // optional stdin source
}
func (j *ffCmd) start(ctx context.Context) error {
@ -272,12 +270,10 @@ func (j *ffCmd) start(ctx context.Context) error {
if j.input != nil {
cmd.Stdin = j.input
}
j.stderr = &bytes.Buffer{}
stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096}
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter)
cmd.Stderr = os.Stderr
} else {
cmd.Stderr = stderrWriter
cmd.Stderr = io.Discard
}
j.cmd = cmd
@ -291,11 +287,7 @@ func (j *ffCmd) wait() {
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
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))
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
} else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
}
@ -304,26 +296,6 @@ func (j *ffCmd) wait() {
_ = 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.
var formatCodecMap = map[string]string{
"mp3": "libmp3lame",

View File

@ -584,12 +584,9 @@ var _ = Describe("ffmpeg", func() {
// Cancel the context
cancel()
// Subsequent reads should eventually fail due to cancelled context.
// There may be buffered data in the pipe, so we drain until an error occurs.
Eventually(func() error {
_, err = stream.Read(buf)
return err
}).WithTimeout(5 * time.Second).WithPolling(10 * time.Millisecond).Should(HaveOccurred())
// Next read should fail due to cancelled context
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred())
})
It("should handle immediate context cancellation", func() {
@ -607,46 +604,6 @@ 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() {
var longRunningCmd string
BeforeEach(func() {

View File

@ -193,10 +193,6 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload
data.Config.CoverArtQuality = conf.Server.CoverArtQuality
data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding
data.Config.UICoverArtSize = conf.Server.UICoverArtSize
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
data.Config.EnableDownloads = conf.Server.EnableDownloads

View File

@ -63,10 +63,6 @@ type Data struct {
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
EnableJukebox bool `json:"enableJukebox,omitempty"`
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
CoverArtQuality int `json:"coverArtQuality,omitempty"`
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`

View File

@ -31,8 +31,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
filteredLines := make([]string, 0, len(lines))
for _, line := range lines {
line := strings.TrimSpace(line)
if after, ok := strings.CutPrefix(line, "#PLAYLIST:"); ok {
pls.Name = after
if strings.HasPrefix(line, "#PLAYLIST:") {
pls.Name = line[len("#PLAYLIST:"):]
continue
}
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {

View File

@ -5,9 +5,8 @@ import (
"fmt"
"io"
"mime"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
@ -18,7 +17,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/req"
)
type MediaStreamer interface {
@ -53,9 +51,6 @@ 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)
}
// 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) {
var format string
var bitRate int
@ -138,59 +133,14 @@ func (s *Stream) EstimatedContentLength() int {
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
}
// Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent
// (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 {
// NewTestStream creates a Stream for testing purposes.
func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream {
return &Stream{
ctx: context.Background(),
mf: mf,
format: format,
bitRate: bitRate,
ReadCloser: r,
ReadCloser: io.NopCloser(strings.NewReader("")),
}
}

View File

@ -1,22 +0,0 @@
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
View File

@ -36,12 +36,12 @@ require (
github.com/kardianos/service v1.2.4
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.0.13
github.com/mattn/go-sqlite3 v1.14.38
github.com/mattn/go-sqlite3 v1.14.34
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.3.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.27.0
github.com/prometheus/client_golang v1.23.2
@ -58,12 +58,12 @@ require (
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.38.0
golang.org/x/net v0.52.0
golang.org/x/image v0.36.0
golang.org/x/net v0.51.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
golang.org/x/time v0.15.0
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/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/ebitengine/purego v0.8.3 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
@ -104,7 +104,7 @@ require (
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/maruel/natural v1.3.0 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
@ -134,10 +134,10 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect

52
go.sum
View File

@ -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/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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc=
github.com/ebitengine/purego v0.8.3/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/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
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/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA=
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
@ -177,8 +177,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@ -199,8 +199,8 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -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.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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -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/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-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -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.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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -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.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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@ -23,7 +23,6 @@ var (
KindAlbumArtwork = Kind{"al", "album"}
KindPlaylistArtwork = Kind{"pl", "playlist"}
KindDiscArtwork = Kind{"dc", "disc"}
KindRadioArtwork = Kind{"ra", "radio"}
)
var artworkKindMap = map[string]Kind{
@ -32,7 +31,6 @@ var artworkKindMap = map[string]Kind{
KindAlbumArtwork.prefix: KindAlbumArtwork,
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
KindDiscArtwork.prefix: KindDiscArtwork,
KindRadioArtwork.prefix: KindRadioArtwork,
}
type ArtworkID struct {
@ -141,11 +139,3 @@ func artworkIDFromArtist(ar Artist) ArtworkID {
ID: ar.ID,
}
}
func artworkIDFromRadio(r Radio) ArtworkID {
return ArtworkID{
Kind: KindRadioArtwork,
ID: r.ID,
LastUpdate: r.UpdatedAt,
}
}

View File

@ -35,7 +35,6 @@ var fieldMap = map[string]*mappedField{
"releasedate": {field: "media_file.release_date"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"missing": {field: "media_file.missing"},
"explicitstatus": {field: "media_file.explicit_status"},
"dateadded": {field: "media_file.created_at"},
"datemodified": {field: "media_file.updated_at"},
@ -50,11 +49,9 @@ var fieldMap = map[string]*mappedField{
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
"filetype": {field: "media_file.suffix"},
"codec": {field: "media_file.codec"},
"duration": {field: "media_file.duration"},
"bitrate": {field: "media_file.bit_rate"},
"bitdepth": {field: "media_file.bit_depth"},
"samplerate": {field: "media_file.sample_rate"},
"bpm": {field: "media_file.bpm"},
"channels": {field: "media_file.channels"},
"loved": {field: "COALESCE(annotation.starred, false)"},

View File

@ -22,9 +22,5 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
if err == nil {
return mf, nil
}
r, err := ds.Radio(ctx).Get(id)
if err == nil {
return r, nil
}
return nil, err
}

View File

@ -65,7 +65,7 @@ var _ = Describe("getPID", func() {
Context("calculated attributes", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,version,releasedate"
})
When("field is title", func() {
It("should return the pid", func() {
@ -88,13 +88,13 @@ var _ = Describe("getPID", func() {
It("should return the pid", func() {
spec := "albumid|title"
md.tags = map[model.TagName][]string{
"title": {"title"},
"album": {"album name"},
"albumversion": {"deluxe edition"},
"releasedate": {"2021-01-01"},
"title": {"title"},
"album": {"album name"},
"version": {"version"},
"releasedate": {"2021-01-01"},
}
mf.AlbumArtist = "Album Artist"
Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\deluxe edition\\2021-01-01))"))
Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))"))
})
})
When("field is albumartistid", func() {

View File

@ -1,27 +1,14 @@
package model
import (
"time"
"github.com/navidrome/navidrome/consts"
)
import "time"
type Radio struct {
ID string `structs:"id" json:"id"`
StreamUrl string `structs:"stream_url" json:"streamUrl"`
Name string `structs:"name" json:"name"`
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
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)
ID string `structs:"id" json:"id"`
StreamUrl string `structs:"stream_url" json:"streamUrl"`
Name string `structs:"name" json:"name"`
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
}
type Radios []Radio
@ -32,5 +19,5 @@ type RadioRepository interface {
Delete(id string) error
Get(id string) (*Radio, error)
GetAll(options ...QueryOptions) (Radios, error)
Put(u *Radio, colsToUpdate ...string) error
Put(u *Radio) error
}

View File

@ -1,42 +0,0 @@
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")))
})
})
})

View File

@ -4,9 +4,7 @@ import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"slices"
"strings"
"time"
@ -14,7 +12,6 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
@ -318,19 +315,7 @@ func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles
}
func (r *artistRepository) purgeEmpty() error {
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)
del := Delete(r.tableName).Where("id not in (select artist_id from album_artists)")
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("purging empty artists: %w", err)
@ -338,19 +323,6 @@ func (r *artistRepository) purgeEmpty() error {
if c > 0 {
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
}

View File

@ -3,14 +3,11 @@ package persistence
import (
"context"
"encoding/json"
"os"
"path/filepath"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
@ -832,89 +829,6 @@ 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.

View File

@ -5,7 +5,6 @@ import (
"path/filepath"
"testing"
"github.com/Masterminds/squirrel"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
@ -212,27 +211,6 @@ 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)
for i := range testSongs {
err := mr.Put(&testSongs[i])

View File

@ -58,20 +58,34 @@ func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, e
return res, err
}
func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error {
func (r *radioRepository) Put(radio *model.Radio) error {
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
var values map[string]any
radio.UpdatedAt = time.Now()
if radio.ID == "" {
radio.CreatedAt = time.Now()
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
}

View File

@ -178,9 +178,7 @@ func buildFTS5Query(userInput string) string {
tokens[i] = t + "*"
}
// 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 ")
result = strings.Join(tokens, " ")
for i, phrase := range phrases {
placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i)

View File

@ -17,33 +17,32 @@ var _ = DescribeTable("buildFTS5Query",
Entry("returns empty string for empty input", "", ""),
Entry("returns empty string for whitespace-only input", " ", ""),
Entry("appends * to a single word for prefix matching", "beatles", "beatles*"),
Entry("appends * to each word for prefix matching", "abbey road", "abbey* AND road*"),
Entry("appends * to each word for prefix matching", "abbey road", "abbey* road*"),
Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`),
Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"),
Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* AND or* AND not* AND near*"),
Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* AND col* AND val*"),
Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND abbey*`),
Entry("handles prefix with multiple words", "beat* abbey", "beat* AND abbey*"),
Entry("collapses multiple spaces", "abbey road", "abbey* AND road*"),
Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* or* not* near*"),
Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* col* val*"),
Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" abbey*`),
Entry("handles prefix with multiple words", "beat* abbey", "beat* abbey*"),
Entry("collapses multiple spaces", "abbey road", "abbey* road*"),
Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"),
Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* AND oliv*"),
Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* oliv*"),
Entry("strips standalone *", "*", ""),
Entry("strips apostrophe from input", "Guns N' Roses", "Guns* AND N* AND Roses*"),
Entry("strips apostrophe from input", "Guns N' Roses", "Guns* N* Roses*"),
Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`),
Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`),
Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`),
Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`),
Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`),
Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`),
Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`),
Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"),
Entry("preserves unicode characters with diacritics", "Björk début", "Björk* AND début*"),
Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* of* ("a ha" OR aha*)`),
Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* roll* vol* 2*"),
Entry("preserves unicode characters with diacritics", "Björk début", "Björk* début*"),
Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`),
Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`),
Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`),
Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* of* "R E M"`),
Entry("collapses two-letter abbreviation", "U.K.", `"U K"`),
Entry("does not collapse single letter surrounded by words", "I am fine", "I* AND am* AND fine*"),
Entry("does not collapse single standalone letter", "A test", "A* AND test*"),
Entry("does not collapse single letter surrounded by words", "I am fine", "I* am* fine*"),
Entry("does not collapse single standalone letter", "A test", "A* test*"),
Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`),
Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`),
Entry("returns empty string for punctuation-only input", "!!!!!!!", ""),

View File

@ -4,12 +4,11 @@ import "context"
// HTTPRequest represents an outbound HTTP request from a plugin.
type HTTPRequest struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
NoFollowRedirects bool `json:"noFollowRedirects,omitempty"`
Body []byte `json:"body,omitempty"`
TimeoutMs int32 `json:"timeoutMs,omitempty"`
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
Body []byte `json:"body,omitempty"`
TimeoutMs int32 `json:"timeoutMs,omitempty"`
}
// HTTPResponse represents the response from an outbound HTTP request.

View File

@ -22,12 +22,6 @@ const (
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.
type httpServiceImpl struct {
pluginName string
@ -50,9 +44,6 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI
// Timeout is set per-request via context deadline, not here.
// CheckRedirect validates hosts and enforces redirect limits.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if req.Context().Value(noFollowRedirectsKey) != nil {
return http.ErrUseLastResponse
}
if len(via) >= httpClientMaxRedirects {
log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via))
return http.ErrUseLastResponse
@ -89,11 +80,6 @@ func (s *httpServiceImpl) Send(ctx context.Context, request host.HTTPRequest) (*
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Signal CheckRedirect to not follow redirects for this request
if request.NoFollowRedirects {
ctx = context.WithValue(ctx, noFollowRedirectsKey, true)
}
// Build request body
method := strings.ToUpper(request.Method)
var body io.Reader

View File

@ -311,26 +311,6 @@ var _ = Describe("httpServiceImpl", func() {
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() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(r.Header.Get("X-Custom")))

View File

@ -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
// WASI filesystem mounting. The test passes ~85% of the time. Using FlakeAttempts
// to automatically retry on failure.
It("should read file from mounted library directory", FlakeAttempts(5), func() {
It("should read file from mounted library directory", FlakeAttempts(3), func() {
ctx := GinkgoT().Context()
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
It("should list files in mounted library directory", FlakeAttempts(5), func() {
It("should list files in mounted library directory", FlakeAttempts(3), func() {
ctx := GinkgoT().Context()
output, err := callTestLibrary(ctx, testLibraryInput{

View File

@ -17,12 +17,11 @@ import (
// HTTPRequest represents the HTTPRequest data structure.
// HTTPRequest represents an outbound HTTP request from a plugin.
type HTTPRequest struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
NoFollowRedirects bool `json:"noFollowRedirects"`
Body []byte `json:"body"`
TimeoutMs int32 `json:"timeoutMs"`
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body []byte `json:"body"`
TimeoutMs int32 `json:"timeoutMs"`
}
// HTTPResponse represents the HTTPResponse data structure.

View File

@ -13,12 +13,11 @@ import "github.com/stretchr/testify/mock"
// HTTPRequest represents the HTTPRequest data structure.
// HTTPRequest represents an outbound HTTP request from a plugin.
type HTTPRequest struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
NoFollowRedirects bool `json:"noFollowRedirects"`
Body []byte `json:"body"`
TimeoutMs int32 `json:"timeoutMs"`
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body []byte `json:"body"`
TimeoutMs int32 `json:"timeoutMs"`
}
// HTTPResponse represents the HTTPResponse data structure.

View File

@ -38,8 +38,6 @@ pub struct HTTPRequest {
#[serde(default)]
pub headers: std::collections::HashMap<String, String>,
#[serde(default)]
pub no_follow_redirects: bool,
#[serde(default)]
#[serde(with = "base64_bytes")]
pub body: Vec<u8>,
#[serde(default)]

View File

@ -38,7 +38,7 @@
"missing": "Manglende",
"libraryName": "Bibliotek",
"composer": "Komponist",
"disc": "Disk %{discNumber}"
"disc": ""
},
"actions": {
"addToQueue": "Afspil senere",
@ -355,7 +355,7 @@
"selectedUsers": "Valgte brugere",
"allLibraries": "Tillad alle biblioteker",
"selectedLibraries": "Valgte biblioteker",
"allowWriteAccess": "Tillad skriveadgang"
"allowWriteAccess": ""
},
"sections": {
"status": "Status",
@ -401,7 +401,7 @@
"requiredHosts": "Påkrævede hosts",
"configValidationError": "Konfigurationsvalidering mislykkedes:",
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.",
"allowWriteAccessHelp": "Når aktiveret, kan denne plugin rette filer i biblioteksmapper. På forhånd har plugins kun læseadgang."
"allowWriteAccessHelp": ""
},
"placeholders": {
"configKey": "nøgle",
@ -591,13 +591,7 @@
"remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.",
"noSimilarSongsFound": "Ingen lignende sange fundet",
"noTopSongsFound": "Ingen topsange fundet",
"startingInstantMix": "Indlæser Instant Mix...",
"uploadCover": "Upload omslag",
"removeCover": "Fjern omslag",
"coverUploaded": "Omslagsbillede opdateret",
"coverRemoved": "Omslagsbillede fjernet",
"coverUploadError": "Fejl ved upload af omslagsbillede",
"coverRemoveError": "Fejl ved fjernelse af omslagsbillede"
"startingInstantMix": "Indlæser Instant Mix..."
},
"menu": {
"library": "Bibliotek",
@ -718,4 +712,4 @@
"empty": "Intet afspilles nu",
"minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden"
}
}
}

View File

@ -18,7 +18,7 @@
"size": "Dateigröße",
"updatedAt": "Hochgeladen am",
"bitRate": "Bitrate",
"discSubtitle": "Disc Untertitel",
"discSubtitle": "CD Untertitel",
"starred": "Favorit",
"comment": "Kommentar",
"rating": "Bewertung",
@ -38,7 +38,7 @@
"missing": "Fehlend",
"libraryName": "Bibliothek",
"composer": "Komponist",
"disc": "Disc %{discNumber}"
"disc": ""
},
"actions": {
"addToQueue": "Später abspielen",
@ -718,4 +718,4 @@
"empty": "Keine Wiedergabe",
"minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten"
}
}
}

View File

@ -38,7 +38,7 @@
"missing": "Απών",
"libraryName": "Βιβλιοθήκη",
"composer": "Συνθέτης",
"disc": "Δίσκος %{discNumber}"
"disc": ""
},
"actions": {
"addToQueue": "Αναπαραγωγη Μετα",
@ -355,7 +355,7 @@
"selectedUsers": "Επιλογή χρηστών",
"allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες",
"selectedLibraries": "Επιλεγμένες βιβλιοθήκες",
"allowWriteAccess": "Επιτρέψτε την πρόσβαση εγγραφής"
"allowWriteAccess": ""
},
"sections": {
"status": "Κατάσταση",
@ -401,7 +401,7 @@
"requiredHosts": "Απαιτούμενοι hosts",
"configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:",
"schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.",
"allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση."
"allowWriteAccessHelp": ""
},
"placeholders": {
"configKey": "κλειδί",
@ -591,13 +591,7 @@
"remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.",
"noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια",
"noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια",
"startingInstantMix": "Φόρτωση Άμεσης Μίξης...",
"uploadCover": "Μεταφόρτωση εξωφύλλου",
"removeCover": "Αφαίρεση καλύμματος",
"coverUploaded": "Το εξώφυλλο ενημερώθηκε",
"coverRemoved": "Το εξώφυλλο αφαιρέθηκε",
"coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου",
"coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου"
"startingInstantMix": "Φόρτωση Άμεσης Μίξης..."
},
"menu": {
"library": "Βιβλιοθήκη",
@ -718,4 +712,4 @@
"empty": "Δεν παίζει τίποτα",
"minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν"
}
}
}

View File

@ -36,9 +36,7 @@
"bitDepth": "Bitprofundo",
"sampleRate": "Elprena rapido",
"missing": "Mankaj",
"libraryName": "Biblioteko",
"composer": "",
"disc": ""
"libraryName": "Biblioteko"
},
"actions": {
"addToQueue": "Ludi Poste",
@ -48,8 +46,7 @@
"download": "Elŝuti",
"playNext": "Ludu Poste",
"info": "Akiri Informon",
"showInPlaylist": "Montri en Ludlisto",
"instantMix": ""
"showInPlaylist": "Montri en Ludlisto"
}
},
"album": {
@ -331,82 +328,6 @@
"scanInProgress": "Skano progresas...",
"noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto"
}
},
"plugin": {
"name": "",
"fields": {
"id": "",
"name": "",
"description": "",
"version": "Versio",
"author": "Aŭtoro",
"website": "Retejo",
"permissions": "Permesoj",
"enabled": "Ebligite",
"status": "",
"path": "Vojo",
"lastError": "Eraro",
"hasError": "Eraro",
"updatedAt": "Ĝisdatigite",
"createdAt": "",
"configKey": "Ŝlosilo",
"configValue": "",
"allUsers": "",
"selectedUsers": "",
"allLibraries": "",
"selectedLibraries": "",
"allowWriteAccess": ""
},
"sections": {
"status": "",
"info": "",
"configuration": "",
"manifest": "",
"usersPermission": "",
"libraryPermission": ""
},
"status": {
"enabled": "",
"disabled": ""
},
"actions": {
"enable": "",
"disable": "",
"disabledDueToError": "",
"disabledUsersRequired": "",
"disabledLibrariesRequired": "",
"addConfig": "",
"rescan": ""
},
"notifications": {
"enabled": "",
"disabled": "",
"updated": "",
"error": ""
},
"validation": {
"invalidJson": ""
},
"messages": {
"configHelp": "",
"clickPermissions": "",
"noConfig": "",
"allUsersHelp": "",
"noUsers": "",
"permissionReason": "",
"usersRequired": "",
"allLibrariesHelp": "",
"noLibraries": "",
"librariesRequired": "",
"requiredHosts": "",
"configValidationError": "",
"schemaRenderError": "",
"allowWriteAccessHelp": ""
},
"placeholders": {
"configKey": "",
"configValue": ""
}
}
},
"ra": {
@ -590,14 +511,7 @@
"remove_all_missing_title": "Forigi ĉiujn mankajn dosierojn",
"remove_all_missing_content": "Ĉu vi certas, ke vi volas forigi ĉiujn mankajn dosierojn de la datumbazo? Ĉi tio permanante forigos ĉiujn referencojn al ili, inkluzive iliajn ludnombrojn kaj taksojn.",
"noSimilarSongsFound": "Neniuj similaj kantoj trovitaj",
"noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj",
"startingInstantMix": "",
"uploadCover": "",
"removeCover": "",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj"
},
"menu": {
"library": "Biblioteko",
@ -683,8 +597,7 @@
"exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
"exportFailed": "Malsukcesis kopii agordojn",
"devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)",
"devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj",
"downloadToml": ""
"devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj"
}
},
"activity": {

View File

@ -38,7 +38,7 @@
"missing": "Faltante",
"libraryName": "Biblioteca",
"composer": "Compositor",
"disc": "Disco %{discNumber}"
"disc": ""
},
"actions": {
"addToQueue": "Reproducir después",
@ -355,7 +355,7 @@
"selectedUsers": "Usuarios seleccionados",
"allLibraries": "Permitir todas las bibliotecas",
"selectedLibraries": "Bibliotecas seleccionadas",
"allowWriteAccess": "Permitir acceso de escritura"
"allowWriteAccess": ""
},
"sections": {
"status": "Estado",
@ -401,7 +401,7 @@
"requiredHosts": "Hosts requeridos",
"configValidationError": "La validación de la configuración falló:",
"schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido.",
"allowWriteAccessHelp": "Cuando está activado, el plugin puede modificar archivos en los directorios de la biblioteca. Por defecto, los plugins tienen acceso de solo lectura."
"allowWriteAccessHelp": ""
},
"placeholders": {
"configKey": "clave",
@ -591,13 +591,7 @@
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"noSimilarSongsFound": "No se encontraron canciones similares",
"noTopSongsFound": "No se encontraron canciones destacadas",
"startingInstantMix": "Cargando la mezcla instantánea...",
"uploadCover": "Subir portada",
"removeCover": "Eliminar portada",
"coverUploaded": "Portada actualizada",
"coverRemoved": "Portada eliminada",
"coverUploadError": "Error al subir la portada",
"coverRemoveError": "Error al eliminar la portada"
"startingInstantMix": "Cargando la mezcla instantánea..."
},
"menu": {
"library": "Biblioteca",
@ -718,4 +712,4 @@
"empty": "Nada en reproducción",
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
}
}
}

View File

@ -23,7 +23,6 @@
"bitDepth": "Bit-sakonera",
"sampleRate": "Lagin-tasa",
"channels": "Kanalak",
"disc": "%{discNumber}. diskoa",
"discSubtitle": "Diskoaren azpititulua",
"starred": "Gogokoa",
"comment": "Iruzkina",
@ -356,8 +355,7 @@
"allUsers": "Baimendu erabiltzaile guztiak",
"selectedUsers": "Hautatutako erabiltzaileak",
"allLibraries": "Baimendu liburutegi guztiak",
"selectedLibraries": "Hautatutako liburutegiak",
"allowWriteAccess": "Eman idazteko baimena"
"selectedLibraries": "Hautatutako liburutegiak"
},
"sections": {
"status": "Egoera",
@ -402,7 +400,6 @@
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
"noLibraries": "Ez da liburutegirik hautatu",
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
"allowWriteAccessHelp": "Gaituta dagoenean, pluginak liburutegien direktorioko fitxategiak moldatu ditzake. Defektuz, pluginek bakarrik irakurtzeko baimena dute.",
"requiredHosts": "Beharrezko ostatatzaileak"
},
"placeholders": {
@ -557,12 +554,6 @@
}
},
"message": {
"uploadCover": "Igo azala",
"removeCover": "Kendu azala",
"coverUploaded": "Diskoaren azala eguneratu da",
"coverRemoved": "Diskoaren azala kendu da",
"coverUploadError": "Errorea diskoaren azala igotzean",
"coverRemoveError": "Errorea diskoaren azala kentzean",
"note": "OHARRA",
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
@ -682,7 +673,6 @@
"currentValue": "Uneko balioa",
"configurationFile": "Konfigurazio-fitxategia",
"exportToml": "Esportatu konfigurazioa (TOML)",
"downloadToml": "Deskargatu konfigurazioa (TOML)",
"exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan",
"exportFailed": "Konfigurazioa kopiatzeak huts egin du",
"devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)",

View File

@ -38,7 +38,7 @@
"missing": "Puuttuva",
"libraryName": "Kirjasto",
"composer": "Säveltäjä",
"disc": "Levy %{discNumber}"
"disc": ""
},
"actions": {
"addToQueue": "Lisää jonoon",
@ -718,4 +718,4 @@
"empty": "Ei soita mitään",
"minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten"
}
}
}

View File

@ -38,7 +38,7 @@
"missing": "Manquant",
"libraryName": "Bibliothèque",
"composer": "Compositeur·e",
"disc": "Disque %{discNumber}"
"disc": ""
},
"actions": {
"addToQueue": "Ajouter à la file",
@ -355,7 +355,7 @@
"selectedUsers": "Utilisateur·rices sélectionné.e.s",
"allLibraries": "Autoriser toutes les bibliothèques",
"selectedLibraries": "Bibliothèques sélectionnées",
"allowWriteAccess": "Autoriser l'accès en écriture"
"allowWriteAccess": ""
},
"sections": {
"status": "Statut",
@ -401,7 +401,7 @@
"requiredHosts": "Hôtes requis",
"configValidationError": "Erreur lors de la validation de la configuration",
"schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide.",
"allowWriteAccessHelp": "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."
"allowWriteAccessHelp": ""
},
"placeholders": {
"configKey": "clef",
@ -591,13 +591,7 @@
"remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.",
"noSimilarSongsFound": "Aucun titre similaire n'a été trouvé",
"noTopSongsFound": "Aucun meilleur titre n'a été trouvé",
"startingInstantMix": "Chargement du mix 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"
"startingInstantMix": "Chargement du mix instantanné..."
},
"menu": {
"library": "Bibliothèque",
@ -718,4 +712,4 @@
"empty": "Aucun titre en cours de lecture",
"minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes"
}
}
}

View File

@ -38,7 +38,7 @@
"missing": "Falta",
"libraryName": "Biblioteca",
"composer": "Composición",
"disc": "Disco %{discNumber}"
"disc": ""
},
"actions": {
"addToQueue": "Ao final da cola",
@ -355,7 +355,7 @@
"selectedUsers": "Usuarias seleccionadas",
"allLibraries": "Permitir todas as bibliotecas",
"selectedLibraries": "Selecciona bibliotecas",
"allowWriteAccess": "Conceder acceso de escritura"
"allowWriteAccess": ""
},
"sections": {
"status": "Estado",
@ -401,7 +401,7 @@
"requiredHosts": "Servidores requeridos",
"configValidationError": "Fallou a comprobación da configuración:",
"schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.",
"allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura."
"allowWriteAccessHelp": ""
},
"placeholders": {
"configKey": "clave",
@ -591,13 +591,7 @@
"remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.",
"noSimilarSongsFound": "Sen cancións parecidas",
"noTopSongsFound": "Sen cancións destacadas",
"startingInstantMix": "Cargando Mestura Súbita…",
"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"
"startingInstantMix": "Cargando Mestura Súbita…"
},
"menu": {
"library": "Biblioteca",
@ -718,4 +712,4 @@
"empty": "Sen reprodución",
"minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos"
}
}
}

View File

@ -22,7 +22,6 @@
"bitRate": "Bitráta",
"bitDepth": "Bitmélység",
"sampleRate": "Mintavételezési frekvencia",
"disc": "Lemez %{discNumber}",
"discSubtitle": "Lemezfelirat",
"starred": "Kedvenc",
"comment": "Megjegyzés",
@ -351,8 +350,7 @@
"allUsers": "Összes felhasználó engedélyezése",
"selectedUsers": "Kiválasztott felhasználók engedélyezése",
"allLibraries": "Összes könyvtár engedélyezése",
"selectedLibraries": "Kiválasztott könyvtárak engedélyezése",
"allowWriteAccess": "Írási hozzáférés engedélyezése"
"selectedLibraries": "Kiválasztott könyvtárak engedélyezése"
},
"sections": {
"status": "Státusz",
@ -397,7 +395,6 @@
"allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.",
"noLibraries": "Nincs kiválasztott könyvtár",
"librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.",
"allowWriteAccessHelp": "Amikor ez engedélyezve van, a kiegészítő módosíthatja a könyvtár mappáit. Alapbeállításon a kiegészítőknek csak olvasási joguk van.",
"requiredHosts": "Szükséges hostok"
},
"placeholders": {
@ -552,12 +549,6 @@
}
},
"message": {
"uploadCover": "Borítókép feltöltése",
"removeCover": "Borítókép törlése",
"coverUploaded": "Borítókép feltöltve",
"coverRemoved": "Borítókép eltávolítva",
"coverUploadError": "Borítókép feltöltése sikertelen",
"coverRemoveError": "Borítókép törlése sikertelen",
"note": "MEGJEGYZÉS",
"transcodingDisabled": "Az átkódolási konfiguráció módosítása a webes felületen keresztül biztonsági okokból nem lehetséges. Ha módosítani szeretnéd az átkódolási beállításokat, indítsd újra a kiszolgálót a %{config} konfigurációs opcióval.",
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",

View File

@ -37,8 +37,7 @@
"sampleRate": "Sample waarde",
"missing": "Ontbrekend",
"libraryName": "Bibliotheek",
"composer": "Componist",
"disc": "Schijf %{discNumber}"
"composer": ""
},
"actions": {
"addToQueue": "Voeg toe aan wachtrij",
@ -49,7 +48,7 @@
"playNext": "Volgende",
"info": "Meer info",
"showInPlaylist": "Toon in afspeellijst",
"instantMix": "Instant mix"
"instantMix": ""
}
},
"album": {
@ -351,11 +350,10 @@
"createdAt": "Geinstalleerd",
"configKey": "Sleutel",
"configValue": "Waarde",
"allUsers": "Sta toe voor alle gebruikers",
"allUsers": "Alle gebruikers toelaten",
"selectedUsers": "Geselecteerde gebruikers",
"allLibraries": "Sta toe voor alle bibliotheken",
"selectedLibraries": "Geselecteerde bibliotheken",
"allowWriteAccess": "Sta schrijftoegang toe"
"allLibraries": "Alle bibliotheken toestaan",
"selectedLibraries": "Geselecteerde bibliotheken"
},
"sections": {
"status": "Status",
@ -381,27 +379,26 @@
"notifications": {
"enabled": "Plugin actief",
"disabled": "Plugin niet actief",
"updated": "Plugin bijgewerkt",
"updated": "Plugin geupdate",
"error": "Fout bij updaten plugin"
},
"validation": {
"invalidJson": "Configuratie moet geldige JSON zijn"
},
"messages": {
"configHelp": "Configureer de plug-in met key-value paren. Leeglaten als de plug-in niet geconfigueerd hoeft te worden.",
"configHelp": "",
"clickPermissions": "Klik op permissie voor details",
"noConfig": "Geen configuratie ingesteld",
"allUsersHelp": "Als dit aanstaat heeft de plug-in toegang tot alle gebruikers, inclusief toekomstige.",
"allUsersHelp": "",
"noUsers": "Geen gebruikers geselecteerd",
"permissionReason": "Reden",
"usersRequired": "Deze plug-in heeft toegang nodig tot gebruikersinformatie. Selecteer welke gebruikers de plug-in toegang toe heeft, of schakel 'sta toe voor alle gebruikers' in.",
"allLibrariesHelp": "Als dit aanstaat, heeft de plug-in toegang tot alle bibliotheken, inclusief toekomstige.",
"usersRequired": "",
"allLibrariesHelp": "",
"noLibraries": "Geen bibliotheken geselecteerd",
"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.",
"librariesRequired": "",
"requiredHosts": "Benodigde hosts",
"configValidationError": "Configuratiecheck mislukt",
"schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.",
"allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen."
"configValidationError": "",
"schemaRenderError": ""
},
"placeholders": {
"configKey": "Sleutel",
@ -591,13 +588,7 @@
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
"noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
"noTopSongsFound": "Geen beste nummers gevonden",
"startingInstantMix": "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"
"startingInstantMix": ""
},
"menu": {
"library": "Bibliotheek",
@ -683,8 +674,7 @@
"exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
"exportFailed": "Kopiëren van configuratie mislukt",
"devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)",
"devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd",
"downloadToml": "Download configuratie (TOML)"
"devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd"
}
},
"activity": {

View File

@ -37,8 +37,7 @@
"sampleRate": "Taxa de amostragem",
"missing": "Ausente",
"libraryName": "Biblioteca",
"composer": "Compositor",
"disc": "Disco %{discNumber}"
"composer": "Compositor"
},
"actions": {
"addToQueue": "Adicionar à fila",
@ -398,10 +397,10 @@
"allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.",
"noLibraries": "Nenhuma biblioteca selecionada",
"librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.",
"allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.",
"requiredHosts": "Hosts necessários",
"configValidationError": "Falha na validação da configuração:",
"schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.",
"allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura."
"schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido."
},
"placeholders": {
"configKey": "chave",
@ -555,6 +554,12 @@
}
},
"message": {
"uploadCover": "Enviar Capa",
"removeCover": "Remover Capa",
"coverUploaded": "Capa atualizada",
"coverRemoved": "Capa removida",
"coverUploadError": "Erro ao enviar capa",
"coverRemoveError": "Erro ao remover capa",
"note": "ATENÇÃO",
"transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}",
"transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão",
@ -591,13 +596,7 @@
"remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
"noSimilarSongsFound": "Nenhuma música semelhante encontrada",
"noTopSongsFound": "Nenhuma música mais tocada encontrada",
"startingInstantMix": "Carregando Mix Instantâneo...",
"uploadCover": "Enviar Capa",
"removeCover": "Remover Capa",
"coverUploaded": "Capa atualizada",
"coverRemoved": "Capa removida",
"coverUploadError": "Erro ao enviar capa",
"coverRemoveError": "Erro ao remover capa"
"startingInstantMix": "Carregando Mix Instantâneo..."
},
"menu": {
"library": "Biblioteca",

View File

@ -1,723 +0,0 @@
{
"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"
}
}
}

View File

@ -38,7 +38,7 @@
"missing": "Saknade",
"libraryName": "Bibliotek",
"composer": "Kompositör",
"disc": "Disc %{discNumber}"
"disc": ""
},
"actions": {
"addToQueue": "Lägg till i kön",
@ -355,7 +355,7 @@
"selectedUsers": "Valda användare",
"allLibraries": "Tillåt alla bibliotek",
"selectedLibraries": "Valda bibliotek",
"allowWriteAccess": "Tillåt skrivrättigheter"
"allowWriteAccess": ""
},
"sections": {
"status": "Status",
@ -401,7 +401,7 @@
"requiredHosts": "Krävda värdar",
"configValidationError": "Validering av konfigurationen misslyckades:",
"schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.",
"allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter."
"allowWriteAccessHelp": ""
},
"placeholders": {
"configKey": "nyckel",
@ -591,13 +591,7 @@
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
"noSimilarSongsFound": "Hittade inga liknande låtar",
"noTopSongsFound": "Hittade inga topplåtar",
"startingInstantMix": "Laddar direktmix...",
"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"
"startingInstantMix": "Laddar direktmix..."
},
"menu": {
"library": "Bibliotek",
@ -718,4 +712,4 @@
"empty": "Inget spelas",
"minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan"
}
}
}

View File

@ -36,9 +36,7 @@
"bitDepth": "Глибина розрядності",
"sampleRate": "Частота дискретизації",
"missing": "Поле відсутнє",
"libraryName": "Бібліотека",
"composer": "Композитор",
"disc": "Диск %{discNumber}"
"libraryName": "Бібліотека"
},
"actions": {
"addToQueue": "Прослухати пізніше",
@ -48,8 +46,7 @@
"download": "Завантажити",
"playNext": "Наступна",
"info": "Отримати інформацію",
"showInPlaylist": "Показати у плейлісті",
"instantMix": "Мікс"
"showInPlaylist": "Показати у плейлісті"
}
},
"album": {
@ -331,82 +328,6 @@
"scanInProgress": "Сканування триває...",
"noLibrariesAssigned": "Немає бібліотек, призначених цьому користувачеві"
}
},
"plugin": {
"name": "Плагін |||| Плагіни",
"fields": {
"id": "ІД",
"name": "Назва",
"description": "Опис",
"version": "Версія",
"author": "Автор",
"website": "Вебсайт",
"permissions": "Дозволи",
"enabled": "Увімкнено",
"status": "Стан",
"path": "Шлях",
"lastError": "Помилка",
"hasError": "Помилка",
"updatedAt": "Оновлено",
"createdAt": "Встановлено",
"configKey": "Ключ",
"configValue": "Значення",
"allUsers": "Дозволити усім користувачам",
"selectedUsers": "Вибрані користувачі",
"allLibraries": "Дозволити всі бібліотеки",
"selectedLibraries": "Вибрані бібліотеки",
"allowWriteAccess": "Дозволити доступ до запису"
},
"sections": {
"status": "Стан",
"info": "Інформація про плагін",
"configuration": "Конфігурація",
"manifest": "Маніфест",
"usersPermission": "Дозволи користувачів",
"libraryPermission": "Дозволи бібліотеки"
},
"status": {
"enabled": "Увімкнено",
"disabled": "Вимкнено"
},
"actions": {
"enable": "Увімкнути",
"disable": "Вимкнути",
"disabledDueToError": "Виправте помилку перед увімкненням",
"disabledUsersRequired": "Виберіть користувачі перед увімкненням",
"disabledLibrariesRequired": "Виберіть бібліотеки перед увімкненням",
"addConfig": "Додати конфігурацію",
"rescan": "Пересканувати"
},
"notifications": {
"enabled": "Увімкнути плагін",
"disabled": "Вимкнути плагін",
"updated": "Плагін оновлено",
"error": "Помилка оновлення плагіну"
},
"validation": {
"invalidJson": "Конфігурація повинна має відповідати формату JSON"
},
"messages": {
"configHelp": "Налаштуйте плагін використовуючи пару ключ-значення. Залиште порожнім, якщо плагін не вимагає конфігурації.",
"clickPermissions": "Натисніть дозволи для детальної інформації",
"noConfig": "Конфігурація не налаштована",
"allUsersHelp": "При увімкненні плагін матиме доступ до всіх користувачів, включно ті, які будуть створені в майбутньому.",
"noUsers": "Немає вибраних користувачів",
"permissionReason": "Причина",
"usersRequired": "Цей плагін вимагає доступу до інформації про користувача. Виберіть, до яких користувачів плагін може отримати доступ, або ввімкніть «Дозволити всім користувачам».",
"allLibrariesHelp": "Коли увімкнуто, плагін матиме доступ до всіх бібліотек, включаючи ті, які будуть створені в майбутньому.",
"noLibraries": "Немає виділених бібліотек",
"librariesRequired": "Цей плагін вимагає доступу до інформації бібліотеки. Виберіть, до яких бібліотек плагін може отримати доступ, або ввімкніть «Дозволити всі бібліотеки».",
"requiredHosts": "Обов'язкові хости",
"configValidationError": "Перевірка конфігурації зазнала невдачі:",
"schemaRenderError": "Неможливо відобразити форму конфігурації. Схема плагіна може бути недійсною.",
"allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання."
},
"placeholders": {
"configKey": "ключ",
"configValue": "значення"
}
}
},
"ra": {
@ -590,14 +511,7 @@
"remove_all_missing_title": "Видалити всі відсутні файли",
"remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами.",
"noSimilarSongsFound": "Не знайдено схожих треків",
"noTopSongsFound": "Не знайдено ТОП-треків",
"startingInstantMix": "Завантаження міксу...",
"uploadCover": "Завантажити обкладинку",
"removeCover": "Видалити обкладинку",
"coverUploaded": "Обкладинку оновлено",
"coverRemoved": "Обкладинка видалена",
"coverUploadError": "Помилка завантаження обкладинки",
"coverRemoveError": "Помилка видалення обкладинки"
"noTopSongsFound": "Не знайдено ТОП-треків"
},
"menu": {
"library": "Бібліотека",
@ -683,8 +597,7 @@
"exportSuccess": "Конфігурацію експортовано в буфер обміну у форматі TOML",
"exportFailed": "Не вдалося скопіювати конфігурацію",
"devFlagsHeader": "Прапорці розробки (можуть бути змінені/видалені)",
"devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях.",
"downloadToml": "Завантажити конфігурацію (TOML)"
"devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях."
}
},
"activity": {

View File

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

View File

@ -81,7 +81,7 @@ main:
albumsort:
aliases: [ tsoa, albumsort, soal, wm/albumsortorder ]
albumversion:
aliases: [albumversion, musicbrainz_albumcomment, musicbrainz album comment]
aliases: [albumversion, musicbrainz_albumcomment, musicbrainz album comment, version]
album: true
genre:
aliases: [ tcon, genre, ©gen, wm/genre, ignr ]

View File

@ -1,133 +0,0 @@
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)
}
}

View File

@ -1,194 +0,0 @@
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
}

View File

@ -33,11 +33,10 @@ func (s *scheduler) Run(ctx context.Context) {
}
func (s *scheduler) Add(crontab string, cmd func()) (int, error) {
schedule, err := ParseCrontab(crontab)
entryID, err := s.c.AddFunc(crontab, cmd)
if err != nil {
return 0, err
}
entryID := s.c.Schedule(schedule, cron.FuncJob(cmd))
return int(entryID), nil
}

View File

@ -1,15 +1,19 @@
package scheduler
import (
"sync"
"testing"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/robfig/cron/v3"
)
func TestScheduler(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Scheduler Suite")
@ -29,22 +33,54 @@ var _ = Describe("Scheduler", func() {
})
It("adds and executes a job", func() {
done := make(chan struct{})
wg := sync.WaitGroup{}
wg.Add(1)
id, err := s.Add("@every 50ms", func() {
close(done)
executed := false
id, err := s.Add("@every 100ms", func() {
executed = true
wg.Done()
})
Expect(err).ToNot(HaveOccurred())
Expect(id).ToNot(BeZero())
Eventually(done).Should(BeClosed())
wg.Wait()
Expect(executed).To(BeTrue())
})
It("adds a job with random ~ syntax", func() {
id, err := s.Add("0~59 * * * *", func() {})
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
}
})
Expect(err).ToNot(HaveOccurred())
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)
// 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))
})
})

View File

@ -1,57 +0,0 @@
#!/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"

View File

@ -11,7 +11,6 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
@ -288,28 +287,18 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool
// spyStreamer captures the Request passed to NewStream for test assertions,
// then returns a minimal fake Stream so the handler completes without error.
type spyStreamer struct {
LastRequest stream.Request
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)
LastRequest stream.Request
LastMediaFile *model.MediaFile
}
func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
s.LastRequest = req
s.LastMediaFile = mf
if s.SimulateError != nil {
return nil, s.SimulateError
}
format := req.Format
if format == "" || format == "raw" {
format = mf.Suffix
}
content := "fake audio data"
if s.SimulateEmptyStream {
content = ""
}
r := io.NopCloser(strings.NewReader(content))
return stream.NewStream(mf, format, req.BitRate, r), nil
return stream.NewTestStream(mf, format, req.BitRate), nil
}
// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods.

View File

@ -1,12 +1,9 @@
package e2e
import (
"encoding/json"
"errors"
"net/http"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -127,56 +124,4 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
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))
})
})
})

View File

@ -1,7 +1,6 @@
package e2e
import (
"errors"
"net/http"
"time"
@ -603,36 +602,6 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
mf.UpdatedAt = originalUpdatedAt
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() {

View File

@ -24,8 +24,8 @@ const maxImageSize = 10 << 20 // 10MB
func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool {
user, _ := request.UserFrom(r.Context())
if !conf.Server.EnableArtworkUpload && !user.IsAdmin {
http.Error(w, "artwork upload is disabled", http.StatusForbidden)
if !conf.Server.EnableCoverArtUpload && !user.IsAdmin {
http.Error(w, "cover art upload is disabled", http.StatusForbidden)
return false
}
return true

View File

@ -71,7 +71,7 @@ func (api *Router) routes() http.Handler {
api.R(r, "/genre", model.Genre{}, false)
api.R(r, "/player", model.Player{}, true)
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
api.addRadioRoute(r)
api.R(r, "/radio", model.Radio{}, true)
api.R(r, "/tag", model.Tag{}, true)
if conf.Server.EnableSharing {
api.RX(r, "/share", api.share.NewRepository, true)

View File

@ -28,8 +28,8 @@ var _ = Describe("Playlist Image Endpoints", func() {
})
DescribeTable("uploadPlaylistImage guard",
func(enableArtworkUpload, isAdmin bool, expectedStatus int) {
conf.Server.EnableArtworkUpload = enableArtworkUpload
func(enableCoverArtUpload, isAdmin bool, expectedStatus int) {
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
handler := uploadPlaylistImage(&mockPlaylistsService{})
req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil)
@ -47,8 +47,8 @@ var _ = Describe("Playlist Image Endpoints", func() {
)
DescribeTable("deletePlaylistImage guard",
func(enableArtworkUpload, isAdmin bool, expectedStatus int) {
conf.Server.EnableArtworkUpload = enableArtworkUpload
func(enableCoverArtUpload, isAdmin bool, expectedStatus int) {
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
handler := deletePlaylistImage(&mockPlaylistsService{})
req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil)

View File

@ -1,70 +0,0 @@
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")
})
}

View File

@ -6,7 +6,6 @@ import (
"net/http"
"path"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/publicurl"
@ -82,7 +81,7 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s
func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share {
s.URL = ShareURL(r, s.ID)
s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize)
s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), consts.UICoverArtSize)
for i := range s.Tracks {
s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID)
}

View File

@ -2,6 +2,7 @@ package public
import (
"errors"
"io"
"net/http"
"strconv"
@ -53,9 +54,34 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
n, err := stream.Serve(ctx, w, r)
if err != nil || n == 0 {
http.Error(w, "internal error", http.StatusInternalServerError)
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 := 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)
}
}
}
}
}

View File

@ -55,14 +55,13 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"defaultLanguage": conf.Server.DefaultLanguage,
"defaultUIVolume": conf.Server.DefaultUIVolume,
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
"uiCoverArtSize": conf.Server.UICoverArtSize,
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
"enableNowPlaying": conf.Server.EnableNowPlaying,
"gaTrackingId": conf.Server.GATrackingID,
"losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")),
"devActivityPanel": conf.Server.DevActivityPanel,
"enableUserEditing": conf.Server.EnableUserEditing,
"enableArtworkUpload": conf.Server.EnableArtworkUpload,
"enableCoverArtUpload": conf.Server.EnableCoverArtUpload,
"enableSharing": conf.Server.EnableSharing,
"shareURL": conf.Server.ShareURL,
"defaultDownloadableShare": conf.Server.DefaultDownloadableShare,

View File

@ -86,7 +86,6 @@ var _ = Describe("serveIndex", func() {
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)),
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),

View File

@ -159,10 +159,6 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response
}
func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubsonicPlaylist {
player, ok := request.PlayerFrom(ctx)
if ok && isClientInList(conf.Server.Subsonic.LegacyClients, player.Client) {
return nil
}
pls := responses.OpenSubsonicPlaylist{}
if p.IsSmartPlaylist() {

View File

@ -128,23 +128,6 @@ var _ = Describe("buildPlaylist", func() {
})
})
Context("with legacy client", func() {
BeforeEach(func() {
conf.Server.Subsonic.LegacyClients = "legacy-client"
player := model.Player{Client: "legacy-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns all standard fields but no OpenSubsonic extensions", func() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
Expect(result.OpenSubsonicPlaylist).To(BeNil())
})
})
Context("when no player in context", func() {
It("returns all fields", func() {
result := router.buildPlaylist(ctx, playlist)
@ -230,23 +213,6 @@ var _ = Describe("buildPlaylist", func() {
Expect(result.ValidUntil).To(Equal(&validUntil))
})
})
Context("with legacy client", func() {
BeforeEach(func() {
conf.Server.Subsonic.LegacyClients = "legacy-client"
player := model.Player{Client: "legacy-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns standard fields but no OpenSubsonic extensions", func() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
Expect(result.OpenSubsonicPlaylist).To(BeNil())
})
})
})
})

View File

@ -2,11 +2,8 @@ package subsonic
import (
"net/http"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
)
@ -69,19 +66,6 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro
StreamUrl: g.StreamUrl,
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()
@ -119,7 +103,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er
Name: name,
}
err = api.ds.Radio(ctx).Put(radio, "StreamUrl", "HomePageUrl", "Name")
err = api.ds.Radio(ctx).Put(radio)
if err != nil {
return nil, err
}

View File

@ -1,146 +0,0 @@
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())
})
})
})

View File

@ -8,7 +8,6 @@
"id": "1",
"name": "album",
"artist": "artist",
"duration": 292,
"genre": "rock",
"userRating": 4,
"genres": [

View File

@ -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">
<album id="1" name="album" artist="artist" duration="292" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 &amp; artist2" explicitStatus="clean" version="Deluxe Edition">
<album id="1" name="album" artist="artist" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 &amp; artist2" explicitStatus="clean" version="Deluxe Edition">
<genres name="rock"></genres>
<genres name="progressive"></genres>
<discTitles disc="1" title="disc 1"></discTitles>

View File

@ -6,7 +6,6 @@
"openSubsonic": true,
"album": {
"id": "",
"name": "",
"duration": 0
"name": ""
}
}

View File

@ -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">
<album id="" name="" duration="0"></album>
<album id="" name=""></album>
</subsonic-response>

View File

@ -7,7 +7,6 @@
"album": {
"id": "",
"name": "",
"duration": 0,
"userRating": 0,
"genres": [],
"musicBrainzId": "",

View File

@ -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">
<album id="" name="" duration="0"></album>
<album id="" name=""></album>
</subsonic-response>

View File

@ -250,7 +250,7 @@ type AlbumID3 struct {
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
Duration int32 `xml:"duration,attr" json:"duration"`
Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
@ -509,15 +509,10 @@ type InternetRadioStations struct {
}
type Radio struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
StreamUrl string `xml:"streamUrl,attr" json:"streamUrl"`
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"`
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
StreamUrl string `xml:"streamUrl,attr" json:"streamUrl"`
HomepageUrl string `xml:"homePageUrl,omitempty,attr" json:"homePageUrl,omitempty"`
}
type JukeboxStatus struct {

View File

@ -288,7 +288,7 @@ var _ = Describe("Responses", func() {
Context("with data", func() {
BeforeEach(func() {
album := AlbumID3{
Id: "1", Name: "album", Artist: "artist", Duration: 292, Genre: "rock",
Id: "1", Name: "album", Artist: "artist", Genre: "rock",
}
album.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},

View File

@ -1,12 +1,15 @@
package subsonic
import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -14,6 +17,38 @@ import (
"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) {
ctx := r.Context()
p := req.Params(r)
@ -46,8 +81,9 @@ 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-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
_, err = stream.Serve(ctx, w, r)
return nil, err
api.serveStream(ctx, w, r, stream, id)
return nil, nil
}
func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
@ -115,18 +151,20 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name())
w.Header().Set("Content-Disposition", disposition)
_, err = stream.Serve(ctx, w, r)
return nil, err
api.serveStream(ctx, w, r, stream, id)
return nil, nil
case *model.Album:
setHeaders(v.Name)
return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)
err = api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)
case *model.Artist:
setHeaders(v.Name)
return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)
err = api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)
case *model.Playlist:
setHeaders(v.Name)
return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)
err = api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)
default:
return nil, model.ErrNotFound
err = model.ErrNotFound
}
return nil, err
}

View File

@ -395,9 +395,7 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*
w.Header().Set("X-Content-Type-Options", "nosniff")
n, err := stream.Serve(ctx, w, r)
if err != nil || n == 0 {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
api.serveStream(ctx, w, r, stream, mediaID)
return nil, nil
}

Some files were not shown because too many files have changed in this diff Show More