Merge branch 'master' into subsonic-folder

This commit is contained in:
Patrik Wallström 2026-04-06 16:09:16 +02:00 committed by GitHub
commit cc514d4121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2884 additions and 603 deletions

View File

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

View File

@ -1,6 +1,8 @@
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
GO_BUILD_TAGS=netgo,sqlite_fts5
comma:=,
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
# Set global environment variables, required for most targets
export CGO_CFLAGS_ALLOW=--define-prefix

View File

@ -70,6 +70,7 @@ type configOptions struct {
MPVCmdTemplate string
CoverArtPriority string
CoverArtQuality int
EnableWebPEncoding bool
ArtistArtPriority string
ArtistImageFolder string
DiscArtPriority string
@ -87,6 +88,7 @@ type configOptions struct {
DefaultLanguage string
DefaultUIVolume int
UISearchDebounceMs int
UICoverArtSize int
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
@ -141,7 +143,6 @@ type configOptions struct {
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
DevEnableMediaFileProbe bool
DevJpegCoverArt bool
}
type scannerOptions struct {
@ -425,6 +426,13 @@ func Load(noConfigDump bool) {
// Removed options
logRemovedOptions("Spotify.ID", "Spotify.Secret")
// Validate other options
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
newValue := max(200, min(1200, Server.UICoverArtSize))
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
Server.UICoverArtSize = newValue
}
// Call init hooks
for _, hook := range hooks {
hook()
@ -713,10 +721,13 @@ func setViperDefaults() {
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("ffmpegpath", "")
viper.SetDefault("mpvpath", "")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverartquality", 75)
viper.SetDefault("enablewebpencoding", false)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
viper.SetDefault("artistimagefolder", "")
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
viper.SetDefault("enablegravatar", false)
@ -727,6 +738,7 @@ 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)
@ -796,6 +808,7 @@ func setViperDefaults() {
viper.SetDefault("plugins.enabled", true)
viper.SetDefault("plugins.cachesize", "200MB")
viper.SetDefault("plugins.autoreload", false)
viper.SetDefault("plugins.loglevel", "")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
@ -809,7 +822,7 @@ func setViperDefaults() {
viper.SetDefault("devuishowconfig", true)
viper.SetDefault("devneweventstream", true)
viper.SetDefault("devoffsetoptimize", 50000)
viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU()))
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
@ -825,7 +838,6 @@ func setViperDefaults() {
viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
viper.SetDefault("devenablemediafileprobe", true)
viper.SetDefault("devjpegcoverart", false)
}
func init() {

View File

@ -85,11 +85,9 @@ const (
)
const (
UICoverArtSize = 600
DefaultUICoverArtSize = 300
)
var CacheWarmerImageSizes = []int{UICoverArtSize}
// Prometheus options
const (
PrometheusDefaultPath = "/metrics"

View File

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

View File

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

View File

@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
@ -182,7 +181,7 @@ var _ = Describe("CacheWarmer", func() {
Eventually(func() []int {
return aw.getCachedSizes()
}).Should(ContainElements(consts.UICoverArtSize))
}).Should(ContainElements(conf.Server.UICoverArtSize))
})
})
})

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 = conf.Server.Agents + hashInput
}
hash := md5.Sum([]byte(hashInput))
return fmt.Sprintf(

View File

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

View File

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

View File

@ -195,6 +195,8 @@ var staticData = sync.OnceValue(func() insights.Data {
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

@ -65,6 +65,8 @@ type Data struct {
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

@ -45,6 +45,9 @@ func PublicURL(req *http.Request, u string, params url.Values) string {
}
buildUrl.Scheme = shareUrl.Scheme
buildUrl.Host = shareUrl.Host
if basePath := strings.TrimRight(shareUrl.Path, "/"); basePath != "" {
buildUrl.Path = path.Join(basePath, buildUrl.Path)
}
if len(params) > 0 {
buildUrl.RawQuery = params.Encode()
}

View File

@ -56,6 +56,31 @@ var _ = Describe("Public URL Utilities", func() {
})
})
When("ShareURL includes a path", func() {
BeforeEach(func() {
conf.Server.ShareURL = "https://example.com/navi"
})
It("prepends the ShareURL path to the resource", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.PublicURL(r, "/share/img/hash", nil)
Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
})
It("prepends the ShareURL path and includes query parameters", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
params := url.Values{"size": []string{"600"}}
result := publicurl.PublicURL(r, "/share/img/hash", params)
Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600"))
})
It("handles trailing slash in ShareURL path", func() {
conf.Server.ShareURL = "https://example.com/navi/"
result := publicurl.PublicURL(nil, "/share/img/hash", nil)
Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
})
})
When("ShareURL is not set", func() {
BeforeEach(func() {
conf.Server.ShareURL = ""

View File

@ -195,6 +195,17 @@ func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) {
return &result, nil
}
// matchesPCMWAVBridge bridges Navidrome's internal "pcm" codec name with the
// "wav" codec name that browsers use to advertise audio/wav support. The match
// is scoped to WAV-container sources so AIFF files (which also normalize to
// codec "pcm" but use a different container) cannot false-match a codec-only
// ["wav"] profile.
func matchesPCMWAVBridge(src *Details, profile *DirectPlayProfile) bool {
return strings.EqualFold(src.Codec, "pcm") &&
strings.EqualFold(src.Container, "wav") &&
containsIgnoreCase(profile.AudioCodecs, "wav")
}
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
// or a typed reason string if it doesn't match.
func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
@ -205,17 +216,17 @@ func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPla
// Check container
if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) {
return "container not supported"
return fmt.Sprintf("container '%s' not supported by profile %s", src.Container, profile)
}
// Check codec
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) {
return "audio codec not supported"
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) && !matchesPCMWAVBridge(src, profile) {
return fmt.Sprintf("audio codec '%s' not supported by profile %s", src.Codec, profile)
}
// Check channels
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
return "audio channels not supported"
return fmt.Sprintf("audio channels %d not supported by profile %s (max %d)", src.Channels, profile, profile.MaxAudioChannels)
}
// Check codec-specific limitations

View File

@ -76,7 +76,10 @@ var _ = Describe("Decider", func() {
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
Expect(decision.TranscodeReasons).To(ContainElement(And(
ContainSubstring("container 'flac' not supported"),
ContainSubstring("[mp3]"),
)))
})
It("rejects direct play when codec doesn't match", func() {
@ -89,7 +92,10 @@ var _ = Describe("Decider", func() {
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported"))
Expect(decision.TranscodeReasons).To(ContainElement(And(
ContainSubstring("audio codec 'alac' not supported"),
ContainSubstring("[m4a/aac]"),
)))
})
It("rejects direct play when channels exceed limit", func() {
@ -102,7 +108,44 @@ var _ = Describe("Decider", func() {
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported"))
Expect(decision.TranscodeReasons).To(ContainElement(And(
ContainSubstring("audio channels 6 not supported"),
ContainSubstring("[flac]"),
ContainSubstring("(max 2)"),
)))
})
It("accepts WAV source against a wav codec profile (pcm->wav bridge)", func() {
// ffprobe normalizes PCM variants (pcm_s16le etc) to codec "pcm", but
// browsers advertise WAV support as audioCodecs:["wav"] via audio/wav MIME.
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "wav", Codec: "pcm", BitRate: 1411, Channels: 2})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"wav"}, AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("does not accept AIFF (pcm in non-wav container) against a wav codec profile", func() {
// AIFF files also normalize to codec="pcm" but use container="aiff".
// Without the container guard they would falsely match a codec-only
// ["wav"] profile and be direct-played as if they were WAV.
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "aiff", Codec: "pcm", BitRate: 1411, Channels: 2})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}},
},
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(ContainElement(ContainSubstring("audio codec 'pcm'")))
})
It("handles container aliases (aac -> m4a)", func() {
@ -216,7 +259,10 @@ var _ = Describe("Decider", func() {
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
Expect(decision.TargetBitrate).To(Equal(256)) // kbps
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
Expect(decision.TranscodeReasons).To(ContainElement(And(
ContainSubstring("container 'flac' not supported"),
ContainSubstring("[mp3]"),
)))
})
It("rejects lossy to lossless transcoding", func() {
@ -901,9 +947,12 @@ var _ = Describe("Decider", func() {
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(HaveLen(3))
Expect(decision.TranscodeReasons[0]).To(Equal("container not supported"))
Expect(decision.TranscodeReasons[1]).To(Equal("container not supported"))
Expect(decision.TranscodeReasons[2]).To(Equal("container not supported"))
Expect(decision.TranscodeReasons[0]).To(ContainSubstring("container 'ogg' not supported"))
Expect(decision.TranscodeReasons[0]).To(ContainSubstring("[flac]"))
Expect(decision.TranscodeReasons[1]).To(ContainSubstring("container 'ogg' not supported"))
Expect(decision.TranscodeReasons[1]).To(ContainSubstring("[mp3/mp3]"))
Expect(decision.TranscodeReasons[2]).To(ContainSubstring("container 'ogg' not supported"))
Expect(decision.TranscodeReasons[2]).To(ContainSubstring("[m4a,mp4/aac]"))
})
})

View File

@ -2,6 +2,7 @@ package stream
import (
"errors"
"strings"
"time"
)
@ -47,6 +48,18 @@ type DirectPlayProfile struct {
MaxAudioChannels int
}
func (p DirectPlayProfile) String() string {
containers := strings.Join(p.Containers, ",")
if containers == "" {
containers = "*"
}
codecs := strings.Join(p.AudioCodecs, ",")
if codecs == "" {
return "[" + containers + "]"
}
return "[" + containers + "/" + codecs + "]"
}
// Profile describes a transcoding target the client supports
type Profile struct {
Container string

View File

@ -0,0 +1,55 @@
-- +goose Up
-- NOTE: This migration recreates two tables to fix schema inconsistencies.
-- On large production databases, the data copy may take some time as tables are locked during the transaction.
-- This is necessary because SQLite does not support altering table constraints directly.
-- Consider applying this migration during a maintenance window if the tables are large.
-- Fix library_artist table: Remove contradictory 'default null' from 'not null' column
-- This is a cosmetic fix (NOT NULL takes precedence), but improves schema consistency
CREATE TABLE library_artist_new
(
library_id integer NOT NULL DEFAULT 1
REFERENCES library(id) ON DELETE CASCADE,
artist_id varchar NOT NULL
REFERENCES artist(id) ON DELETE CASCADE,
stats text DEFAULT '{}',
CONSTRAINT library_artist_ux UNIQUE (library_id, artist_id)
);
INSERT INTO library_artist_new (library_id, artist_id, stats)
SELECT library_id, artist_id, stats FROM library_artist;
DROP TABLE library_artist;
ALTER TABLE library_artist_new RENAME TO library_artist;
-- Fix scrobble_buffer table: Remove duplicate user_id from unique constraint
-- Original constraint had: UNIQUE (user_id, service, media_file_id, play_time, user_id)
-- Fixed constraint is: UNIQUE (user_id, service, media_file_id, play_time)
CREATE TABLE scrobble_buffer_new
(
user_id varchar NOT NULL
CONSTRAINT scrobble_buffer_user_id_fk
REFERENCES user ON UPDATE CASCADE ON DELETE CASCADE,
service varchar NOT NULL,
media_file_id varchar NOT NULL
CONSTRAINT scrobble_buffer_media_file_id_fk
REFERENCES media_file ON UPDATE CASCADE ON DELETE CASCADE,
play_time datetime NOT NULL,
enqueue_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
id varchar NOT NULL DEFAULT '',
CONSTRAINT scrobble_buffer_pk UNIQUE (user_id, service, media_file_id, play_time)
);
INSERT INTO scrobble_buffer_new (user_id, service, media_file_id, play_time, enqueue_time, id)
SELECT user_id, service, media_file_id, play_time, enqueue_time, id FROM scrobble_buffer;
DROP TABLE scrobble_buffer;
ALTER TABLE scrobble_buffer_new RENAME TO scrobble_buffer;
CREATE UNIQUE INDEX scrobble_buffer_id_ix ON scrobble_buffer (id);
-- +goose Down
-- Down migration is intentionally a no-op: Navidrome does not run down migrations.

8
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.37
github.com/mattn/go-sqlite3 v1.14.38
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pelletier/go-toml/v2 v2.3.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.27.0
github.com/prometheus/client_golang v1.23.2
@ -58,7 +58,7 @@ require (
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.37.0
golang.org/x/image v0.38.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
@ -104,7 +104,7 @@ require (
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/maruel/natural v1.3.0 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect

16
go.sum
View File

@ -167,8 +167,8 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA=
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
@ -177,8 +177,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@ -199,8 +199,8 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -323,8 +323,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,6 +55,7 @@ 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,

View File

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

View File

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

View File

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

View File

@ -75,8 +75,12 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro
continue
}
// Add coverArt if not legacy client
var coverArt string
if g.UploadedImage != "" {
coverArt = g.CoverArtID().String()
}
res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{
CoverArt: g.UploadedImage,
CoverArt: coverArt,
}
}

View File

@ -71,7 +71,7 @@ var _ = Describe("Radio", func() {
Expect(err).ToNot(HaveOccurred())
Expect(response.InternetRadioStations.Radios).To(HaveLen(2))
Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil())
Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("rd-1_cover.jpg"))
Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0"))
Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil())
Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty())
})
@ -129,7 +129,7 @@ var _ = Describe("Radio", func() {
Expect(err).ToNot(HaveOccurred())
Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil())
Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("rd-1_cover.jpg"))
Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0"))
})
})

2793
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ import {
useTranslate,
} from 'react-admin'
import Lightbox from 'react-image-lightbox'
import { COVER_ART_SIZE } from '../consts'
import config from '../config'
import 'react-image-lightbox/style.css'
import subsonic from '../subsonic'
import {
@ -32,7 +32,6 @@ import {
useAlbumsPerPage,
useImageLoadingState,
} from '../common'
import config from '../config'
import { formatFullDate, intersperse } from '../utils'
import AlbumExternalLinks from './AlbumExternalLinks'
import { SafeHTML } from '../common/SafeHTML'
@ -255,7 +254,7 @@ const AlbumDetails = (props) => {
})
}, [record])
const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE)
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize)
const fullImageUrl = subsonic.getCoverArtUrl(record)
return (

View File

@ -20,7 +20,8 @@ import {
OverflowTooltip,
useImageUrl,
} from '../common'
import { COVER_ART_SIZE, DraggableTypes } from '../consts'
import config from '../config'
import { DraggableTypes } from '../consts'
import clsx from 'clsx'
import { AlbumDatesField } from './AlbumDatesField.jsx'
@ -135,7 +136,7 @@ const Cover = withContentRect('bounds')(({
[record],
)
const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)
const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
const { imgUrl, loading: imageLoading } = useImageUrl(url)
return (

View File

@ -15,7 +15,6 @@ import {
import Lightbox from 'react-image-lightbox'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo'
import { COVER_ART_SIZE } from '../consts'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
@ -110,7 +109,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
<CardMedia
key={record.id}
component="img"
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}

View File

@ -11,7 +11,6 @@ import {
useImageLoadingState,
} from '../common'
import Lightbox from 'react-image-lightbox'
import { COVER_ART_SIZE } from '../consts'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
@ -113,7 +112,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
<CardMedia
key={record.id}
component="img"
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}

View File

@ -2,7 +2,7 @@ import { useRecordContext } from 'react-admin'
import { Avatar } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import clsx from 'clsx'
import { COVER_ART_SIZE } from '../consts'
import config from '../config'
import subsonic from '../subsonic'
import { useImageUrl } from './useImageUrl'
@ -28,7 +28,7 @@ export const CoverArtAvatar = ({
const record = recordProp || recordContext
const square = variant !== 'circular'
const url = record
? subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)
? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square)
: null
const { imgUrl } = useImageUrl(url)
if (!record) return null

View File

@ -51,6 +51,7 @@ const useStyles = makeStyles({
borderRadius: '4px',
flexShrink: 0,
cursor: 'pointer',
filter: 'none !important',
},
row: {
cursor: 'pointer',

View File

@ -21,6 +21,7 @@ const defaultConfig = {
defaultLanguage: '',
defaultUIVolume: 100,
uiSearchDebounceMs: 200,
uiCoverArtSize: 600,
enableUserEditing: true,
enableArtworkUpload: true,
enableSharing: true,

View File

@ -26,8 +26,6 @@ DraggableTypes.ALL.push(
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
export const COVER_ART_SIZE = 600
export const DEFAULT_SHARE_BITRATE = 128
export const BITRATE_CHOICES = [

View File

@ -18,7 +18,7 @@ import {
OverflowTooltip,
useImageLoadingState,
} from '../common'
import { COVER_ART_SIZE } from '../consts'
import config from '../config'
import subsonic from '../subsonic'
const useStyles = makeStyles(
@ -107,7 +107,7 @@ const PlaylistDetails = (props) => {
handleCloseLightbox,
} = useImageLoadingState(record.id)
const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
const fullImageUrl = subsonic.getCoverArtUrl(record)
return (

View File

@ -11,7 +11,8 @@ import { makeStyles } from '@material-ui/core/styles'
import { urlValidate } from '../utils/validations'
import { Title, ImageUploadOverlay, useImageLoadingState } from '../common'
import subsonic from '../subsonic'
import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts'
import config from '../config'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
const useStyles = makeStyles({
coverParent: {
@ -83,7 +84,7 @@ const RadioCoverArt = ({ record }) => {
{record.uploadedImage ? (
<CardMedia
component="img"
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)}
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onLoad={handleImageLoad}
onError={handleImageError}

View File

@ -1,5 +1,6 @@
import subsonic from '../subsonic'
import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts'
import config from '../config'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
export async function songFromRadio(radio) {
if (!radio) {
@ -8,7 +9,7 @@ export async function songFromRadio(radio) {
let cover = RADIO_PLACEHOLDER_IMAGE
if (radio.uploadedImage) {
cover = subsonic.getCoverArtUrl(radio, COVER_ART_SIZE, true)
cover = subsonic.getCoverArtUrl(radio, config.uiCoverArtSize, true)
} else {
// Try favicon as fallback
try {

View File

@ -1,5 +1,5 @@
import { vi } from 'vitest'
import { COVER_ART_SIZE } from '../consts'
import config from '../config'
import subsonic from './index'
describe('getCoverArtUrl', () => {
@ -31,7 +31,11 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true)
const url = subsonic.getCoverArtUrl(
playlistRecord,
config.uiCoverArtSize,
true,
)
expect(url).toContain('pl-playlist-123')
expect(url).toContain('size=600')
@ -45,7 +49,11 @@ describe('getCoverArtUrl', () => {
sync: true,
}
const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true)
const url = subsonic.getCoverArtUrl(
playlistRecord,
config.uiCoverArtSize,
true,
)
expect(url).toContain('pl-playlist-123')
expect(url).toContain('size=600')
@ -60,7 +68,11 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(albumRecord, COVER_ART_SIZE, true)
const url = subsonic.getCoverArtUrl(
albumRecord,
config.uiCoverArtSize,
true,
)
expect(url).toContain('al-album-123')
expect(url).toContain('size=600')
@ -74,7 +86,7 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(songRecord, COVER_ART_SIZE, true)
const url = subsonic.getCoverArtUrl(songRecord, config.uiCoverArtSize, true)
expect(url).toContain('mf-song-123')
expect(url).toContain('size=600')
@ -87,7 +99,11 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(artistRecord, COVER_ART_SIZE, true)
const url = subsonic.getCoverArtUrl(
artistRecord,
config.uiCoverArtSize,
true,
)
expect(url).toContain('ar-artist-123')
expect(url).toContain('size=600')