fix cursor, allow descending order

This commit is contained in:
Kendall Garner 2026-08-06 20:32:29 -07:00
commit 44a933f72e
No known key found for this signature in database
GPG Key ID: 9355F387FE765C94
107 changed files with 3681 additions and 452 deletions

1
.gitignore vendored
View File

@ -40,3 +40,4 @@ openspec/
.agents
go.work*
.worktrees/
.playwright-mcp/

View File

@ -69,6 +69,7 @@ linters:
- examples$
- node_modules
- _gen\.go$
- .worktrees
formatters:
exclusions:
generated: lax

View File

@ -394,7 +394,7 @@ func init() {
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint")
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))

View File

@ -2,14 +2,18 @@ package conf
import (
"cmp"
"encoding"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/bmatcuk/doublestar/v4"
@ -21,11 +25,12 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/scheduler"
"github.com/navidrome/navidrome/utils/run"
"github.com/navidrome/navidrome/utils/slice"
"github.com/spf13/viper"
)
type configOptions struct {
ConfigFile string
ConfigFile string `conf:"-"`
Address string
Port int
UnixSocketPerm string
@ -201,7 +206,7 @@ type lastfmOptions struct {
ScrobbleFirstArtistOnly bool
// Computed values
Languages []string // Computed from Language, split by comma
Languages []string `conf:"-"` // Computed from Language, split by comma
}
type deezerOptions struct {
@ -209,7 +214,7 @@ type deezerOptions struct {
Language string
// Computed values
Languages []string // Computed from Language, split by comma
Languages []string `conf:"-"` // Computed from Language, split by comma
}
type listenBrainzOptions struct {
@ -340,12 +345,11 @@ func Load(noConfigDump bool) {
remapEnvVarKeysFromConfig()
// Map deprecated options to their new names for backwards compatibility
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
for _, o := range deprecatedOptions {
if o.replacement != "" {
mapDeprecatedOption(o.name, o.replacement)
}
}
err := viper.Unmarshal(&Server, viper.DecodeHook(
mapstructure.ComposeDecodeHookFunc(
@ -403,6 +407,13 @@ func Load(noConfigDump bool) {
log.SetLogSourceLine(Server.DevLogSourceLine)
log.SetRedacting(Server.EnableLogRedacting)
// Log deprecated, removed and unknown options
for _, o := range deprecatedOptions {
logDeprecatedOptions(o.name, o.replacement)
}
logRemovedOptions(removedOptions...)
logUnknownOptions()
err = run.Sequentially(
validateScanSchedule,
validateBackupSchedule,
@ -461,21 +472,6 @@ func Load(noConfigDump bool) {
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
// Deprecated options
logDeprecatedOptions("Scanner.GenreSeparators", "")
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("SearchFullString", "Search.FullString")
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
// Removed options
logRemovedOptions("Spotify.ID", "Spotify.Secret")
// Validate other options
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
newValue := max(200, min(1200, Server.UICoverArtSize))
@ -489,9 +485,26 @@ func Load(noConfigDump bool) {
}
}
// deprecatedOptions still work, but will be removed in a future release. An empty
// replacement means the option is now ignored.
var deprecatedOptions = []struct{ name, replacement string }{
{"Scanner.GenreSeparators", ""},
{"Scanner.GroupAlbumReleases", ""},
{"DevEnableBufferedScrobble", ""},
{"SearchFullString", "Search.FullString"},
{"ReverseProxyWhitelist", "ExtAuth.TrustedSources"},
{"ReverseProxyUserHeader", "ExtAuth.UserHeader"},
{"HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions"},
{"CoverJpegQuality", "CoverArtQuality"},
{"SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold"},
{"EnableTranscodingCancellation", "Transcoding.EnableCancellation"},
}
var removedOptions = []string{"Spotify.ID", "Spotify.Secret"}
func logDeprecatedOptions(oldName, newName string) {
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
envVar := envVarName(oldName)
newEnvVar := envVarName(newName)
logWarning := func(oldName, newName string) {
if newName != "" {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
@ -511,7 +524,7 @@ func logDeprecatedOptions(oldName, newName string) {
// not available anymore
func logRemovedOptions(options ...string) {
for _, option := range options {
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
envVar := envVarName(option)
logWarning := func(option string) {
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
}
@ -532,35 +545,193 @@ func remapEnvVarKeysFromConfig() {
continue
}
stripped := strings.TrimPrefix(key, "nd_")
canonicalKey := strings.ReplaceAll(stripped, "_", ".")
canonicalKey := ndKeyToCanonical(key)
displayNDKey := "ND_" + strings.ToUpper(stripped)
displayCanonical := toPascalCase(canonicalKey)
canonicalName := canonicalOptionName(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,
displayNDKey, cmp.Or(canonicalName, toPascalCase(canonicalKey)),
))
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,
)
// Unknown keys get no advice here, logUnknownOptions reports them instead
if canonicalName != "" {
_, _ = 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, canonicalName,
)
}
}
}
// 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) {
if viper.IsSet(legacyName) {
// viper.Set outranks the config file, so an explicit replacement must win over the legacy value
if viper.IsSet(legacyName) && !explicitlySet(newName) {
viper.Set(newName, viper.Get(legacyName))
}
}
// explicitlySet reports whether the user provided the option, ignoring defaults,
// which viper.IsSet counts as set. The ND_ spelling is also accepted in the config
// file, and remapEnvVarKeysFromConfig has already moved it out of InConfig's reach.
func explicitlySet(name string) bool {
envVar := envVarName(name)
return viper.InConfig(name) || os.Getenv(envVar) != "" || viper.InConfig(strings.ToLower(envVar))
}
func envVarName(option string) string {
if option == "" {
return ""
}
return "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
}
func logUnknownOptions() {
for _, key := range unknownConfigKeys() {
msg := fmt.Sprintf("Option '%s' is not recognized and will be ignored", key)
if matches := suggestOptions(key); len(matches) > 0 {
msg += fmt.Sprintf(". Did you mean '%s'?", strings.Join(matches, "' or '"))
}
log.Warn(msg)
}
}
// suggestOptions returns the known options sharing the last segment with key,
// catching options written outside their section.
func suggestOptions(key string) []string {
key = strings.ToLower(key)
leaf := leafKey(key)
canonical, _ := configKeys()
var matches []string
for known, name := range canonical {
// Removed options are known only so they get their own warning, never suggest them
if known != key && leafKey(known) == leaf && !slices.Contains(removedOptions, name) {
matches = append(matches, name)
}
}
slices.Sort(matches)
return matches
}
func leafKey(key string) string {
return key[strings.LastIndex(key, ".")+1:]
}
// unknownConfigKeys returns config file keys that don't match any known option, so
// typos and options written outside their section don't fail silently.
func unknownConfigKeys() []string {
// INI files keep the original [default] section alongside the merged one
skipDefault := strings.EqualFold(filepath.Ext(viper.ConfigFileUsed()), ".ini")
var unknown []string
for _, key := range viper.AllKeys() {
if !viper.InConfig(key) || canonicalOptionName(key) != "" {
continue
}
if skipDefault && strings.HasPrefix(key, "default.") {
continue
}
// Only ND_-prefixed keys that remapEnvVarKeysFromConfig could resolve are valid
if strings.HasPrefix(key, "nd_") && canonicalOptionName(ndKeyToCanonical(key)) != "" {
continue
}
unknown = append(unknown, key)
}
slices.Sort(unknown)
return asWrittenInConfigFile(unknown)
}
func ndKeyToCanonical(key string) string {
return strings.ReplaceAll(strings.TrimPrefix(key, "nd_"), "_", ".")
}
// canonicalOptionName returns the documented spelling of a known option key, or ""
// if it matches no option. Subkeys of free-form maps have no fixed spelling.
func canonicalOptionName(key string) string {
keys, prefixes := configKeys()
if name, ok := keys[key]; ok {
return name
}
if slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(key, p) }) {
return toPascalCase(key)
}
return ""
}
// asWrittenInConfigFile restores the casing the keys have in the config file, as
// viper lowercases every key it loads.
func asWrittenInConfigFile(keys []string) []string {
if len(keys) == 0 {
return nil
}
data, err := os.ReadFile(viper.ConfigFileUsed())
if err != nil {
return keys
}
casing := map[string]string{}
for _, match := range configFileKeyRx.FindAllStringSubmatch(string(data), -1) {
for segment := range strings.SplitSeq(match[1], ".") {
lower := strings.ToLower(segment)
casing[lower] = cmp.Or(casing[lower], segment)
}
}
return slice.Map(keys, func(key string) string {
segments := strings.Split(key, ".")
for i, s := range segments {
segments[i] = cmp.Or(casing[s], s)
}
return strings.Join(segments, ".")
})
}
// Matches keys and section headers in all supported config formats.
var configFileKeyRx = regexp.MustCompile(`(?m)^\s*\[?\s*"?([\w.]+)"?\s*[]=:]`)
// configKeys maps every accepted option name, lowercased, to its canonical spelling,
// plus the prefixes of free-form map options (Tags, DevLogLevels).
var configKeys = sync.OnceValues(func() (map[string]string, []string) {
keys := map[string]string{}
var prefixes []string
var collect func(t reflect.Type, prefix string)
collect = func(t reflect.Type, prefix string) {
for field := range t.Fields() {
// `conf:"-"` marks values computed during Load, not settable in the config
if !field.IsExported() || field.Tag.Get("conf") == "-" {
continue
}
name := prefix + field.Name
if field.Type.Kind() == reflect.Struct && !reflect.PointerTo(field.Type).Implements(textUnmarshalerType) {
collect(field.Type, name+".")
continue
}
lower := strings.ToLower(name)
keys[lower] = name
if field.Type.Kind() == reflect.Map {
prefixes = append(prefixes, lower+".")
}
}
}
collect(reflect.TypeFor[configOptions](), "")
for _, o := range deprecatedOptions {
keys[strings.ToLower(o.name)] = o.name
}
for _, o := range removedOptions {
keys[strings.ToLower(o)] = o
}
return keys, prefixes
})
var textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level.

View File

@ -1,12 +1,14 @@
package conf_test
import (
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/viper"
@ -178,6 +180,123 @@ var _ = Describe("Configuration", func() {
})
})
Describe("unknownConfigKeys", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
It("reports misplaced and misspelled options, as spelled in the config file", func() {
conf.InitConfig(filepath.Join("testdata", "cfg_unknown_keys.toml"), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(ConsistOf(
"ArtistSplitExceptions", "EnableDownlods", "Whatever.Foo",
))
})
DescribeTable("recovers the original casing in all supported formats",
func(file string) {
conf.InitConfig(filepath.Join("testdata", file), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(ConsistOf("NotAnOption"))
},
Entry("TOML", "cfg_unknown_casing.toml"),
Entry("YAML", "cfg_unknown_casing.yaml"),
Entry("JSON", "cfg_unknown_casing.json"),
Entry("INI", "cfg_unknown_casing.ini"),
)
It("does not report valid, deprecated or free-form keys", func() {
conf.InitConfig(filepath.Join("testdata", "cfg.toml"), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
})
It("does not report the [default] section of INI files", func() {
conf.InitConfig(filepath.Join("testdata", "cfg.ini"), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
})
DescribeTable("SuggestOptions",
func(key string, expected []string) {
Expect(conf.SuggestOptions(key)).To(Equal(expected))
},
Entry("suggests the section of a misplaced option", "artistsplitexceptions",
[]string{"Scanner.ArtistSplitExceptions"}),
Entry("suggests the section of a misplaced nested option", "backup.fuzzythreshold",
[]string{"Matcher.FuzzyThreshold"}),
Entry("suggests every section defining the option", "schedule",
[]string{"Backup.Schedule", "Scanner.Schedule"}),
Entry("suggests nothing for a typo", "enabledownlods", nil),
)
It("does not report ND_-prefixed keys, as they are remapped", func() {
conf.InitConfig(filepath.Join("testdata", "cfg_nd_keys.toml"), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
})
It("reports ND_-prefixed keys that remap to no known option", func() {
conf.InitConfig(filepath.Join("testdata", "cfg_nd_bogus.toml"), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION"))
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
})
It("migrates every deprecated option that has a replacement", func() {
conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false)
conf.Load(true)
Expect(conf.Server.Search.FullString).To(BeTrue())
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
})
It("warns about each unrecognized option at startup", func() {
var logBuf bytes.Buffer
log.SetOutput(&logBuf)
DeferCleanup(func() { log.SetOutput(GinkgoWriter) })
conf.InitConfig(filepath.Join("testdata", "cfg_warning_output.toml"), false)
conf.Load(true)
Expect(logBuf.String()).To(ContainSubstring(
"Option 'ArtistSplitExceptions' is not recognized and will be ignored. " +
"Did you mean 'Scanner.ArtistSplitExceptions'?"))
Expect(logBuf.String()).To(ContainSubstring(
"Option 'EnableDownlods' is not recognized and will be ignored"))
Expect(logBuf.String()).ToNot(ContainSubstring("ArtistJoiner"))
})
Context("with runtime-computed and removed options in the config", func() {
BeforeEach(func() {
conf.InitConfig(filepath.Join("testdata", "cfg_runtime_fields.toml"), false)
conf.Load(true)
})
It("reports values computed during Load, which the config cannot set", func() {
Expect(conf.UnknownConfigKeys()).To(ContainElements("ConfigFile", "LastFM.Languages"))
})
It("never suggests a removed option", func() {
Expect(conf.SuggestOptions("id")).To(BeEmpty())
})
It("keeps an explicit replacement over the deprecated value", func() {
Expect(conf.Server.Search.FullString).To(BeFalse())
})
})
})
Describe("logFatal", func() {
var invalidPath string
BeforeEach(func() {

View File

@ -32,3 +32,7 @@ func SetLogFatal(f func(...any)) func() {
logFatal = f
return func() { logFatal = old }
}
var UnknownConfigKeys = unknownConfigKeys
var SuggestOptions = suggestOptions

View File

@ -0,0 +1,2 @@
MusicFolder = "/toml/music"
SearchFullString = true

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

@ -0,0 +1,3 @@
MusicFolder = "/toml/music"
ND_TOTALLY_BOGUS_OPTION = true
ND_SCANNER_SCHEDULE = "@every 1h"

10
conf/testdata/cfg_runtime_fields.toml vendored Normal file
View File

@ -0,0 +1,10 @@
MusicFolder = "/toml/music"
SearchFullString = true
ConfigFile = "/somewhere/else"
ID = "oops"
[Search]
FullString = false
[LastFM]
Languages = ["pt"]

3
conf/testdata/cfg_unknown_casing.ini vendored Normal file
View File

@ -0,0 +1,3 @@
[default]
MusicFolder = /ini/music
NotAnOption = true

4
conf/testdata/cfg_unknown_casing.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"MusicFolder": "/json/music",
"NotAnOption": true
}

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

@ -0,0 +1,2 @@
MusicFolder = "/toml/music"
NotAnOption = true

2
conf/testdata/cfg_unknown_casing.yaml vendored Normal file
View File

@ -0,0 +1,2 @@
MusicFolder: /yaml/music
NotAnOption: true

18
conf/testdata/cfg_unknown_keys.toml vendored Normal file
View File

@ -0,0 +1,18 @@
MusicFolder = "/toml/music"
# Valid option, but written at the root level instead of under Scanner
ArtistSplitExceptions = ["AC/DC", "Tyler, the creator"]
# Misspelled option
EnableDownlods = true
# Unknown section
[Whatever]
Foo = "bar"
# Valid options, must not be reported
[Scanner]
ArtistJoiner = " • "
[Tags.custom]
aliases = ["toml", "test"]

7
conf/testdata/cfg_warning_output.toml vendored Normal file
View File

@ -0,0 +1,7 @@
MusicFolder = "/toml/music"
LogLevel = "warn"
ArtistSplitExceptions = ["AC/DC"]
EnableDownlods = true
[Scanner]
ArtistJoiner = " • "

View File

@ -28,6 +28,7 @@ const (
UIAuthorizationHeader = "X-ND-Authorization"
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
JWTSecretKey = "JWTSecret"
JWTPublicSecretKey = "JWTPublicSecret"
JWTIssuer = "ND"
DefaultSessionTimeout = 48 * time.Hour
CookieExpiry = 365 * 24 * 3600 // One year

View File

@ -21,7 +21,7 @@ import (
type Archiver interface {
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
ZipShare(ctx context.Context, id string, w io.Writer) error
ZipShare(ctx context.Context, s *model.Share, w io.Writer) error
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
@ -100,16 +100,14 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
}
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
s, err := a.shares.Load(ctx, id)
if err != nil {
return err
}
// ZipShare takes an already-loaded share: Share.Load records a visit, so
// loading it again here would count every download twice.
func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) error {
if !s.Downloadable {
return model.ErrNotAuthorized
}
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
}
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {

View File

@ -130,13 +130,16 @@ var _ = Describe("Archiver", func() {
Tracks: mfs,
}
sh.On("Load", mock.Anything, "1").Return(share, nil)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipShare(context.Background(), "1", out)
err := arch.ZipShare(context.Background(), share, out)
Expect(err).To(BeNil())
// Share.Load records a visit; re-loading here would double-count
// every download.
sh.AssertNotCalled(GinkgoT(), "Load", mock.Anything, mock.Anything)
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).To(BeNil())

View File

@ -202,6 +202,7 @@ var _ = Describe("Artwork", func() {
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
folderRepo.result = []model.Folder{{
ID: "f1",
LibraryPath: testFileLibPath(repoRoot),
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},

View File

@ -80,6 +80,101 @@ var _ = Describe("Artist artwork resolution", func() {
})
})
When("ArtistArtPriority has no album/ fallback", func() {
// Artist/
// ├── artist.jpg ← must resolve via the artist folder itself
// └── Album/
// └── 01 - Track.mp3
It("still resolves the artist folder and returns artist.*", func() {
conf.Server.ArtistArtPriority = "artist.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": imageFile("artist-folder"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
When("the artist's only album has its tracks in disc subfolders", func() {
// Artist/
// ├── artist.jpg ← wins (artist.* before album/artist.*)
// └── Album/
// ├── artist.jpg
// ├── CD1/01 - Track.mp3
// └── CD2/02 - Track.mp3
It("prefers the artist-folder image over the album-folder one", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
"Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
"Artist/artist.jpg": imageFile("artist-folder"),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
When("one album has disc subfolders and another sits at artist level", func() {
// Artist/
// ├── artist.jpg ← wins
// ├── Album1/
// │ ├── artist.jpg
// │ ├── CD1/01 - Track.mp3
// │ └── CD2/02 - Track.mp3
// └── Album2/03 - Track.mp3
It("prefers the artist-folder image over the album-folder one", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
"Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}),
"Artist/artist.jpg": imageFile("artist-folder"),
"Artist/Album1/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
When("every album of the artist has its tracks in disc subfolders", func() {
// Artist/
// ├── artist.jpg ← wins
// ├── Album1/
// │ ├── artist.jpg
// │ ├── CD1/01 - Track.mp3
// │ └── CD2/02 - Track.mp3
// └── Album2/
// ├── CD1/03 - Track.mp3
// └── CD2/04 - Track.mp3
It("prefers the artist-folder image over the album-folder one", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
"Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}),
"Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}),
"Artist/artist.jpg": imageFile("artist-folder"),
"Artist/Album1/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
When("an artist has an uploaded image and a matching artist.* file", func() {
// <DataFolder>/
// └── artwork/

View File

@ -171,7 +171,7 @@ func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*mo
func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) {
func (n *noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {

View File

@ -20,6 +20,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/natural"
"github.com/navidrome/navidrome/utils/slice"
)
type albumArtworkReader struct {
@ -103,17 +104,13 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
return ff
}
func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...model.Album) ([]string, []string, *time.Time, error) {
var folderIDs []string
for _, album := range albums {
folderIDs = append(folderIDs, album.FolderIDs...)
}
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.Album) ([]string, []string, *time.Time, error) {
folders, err := loadFolders(ctx, ds, album.FolderIDs)
if err != nil {
return nil, nil, nil, err
}
parent, err := albumRootParent(ctx, ds, folders, folderIDs)
parent, err := albumRootParent(ctx, ds, folders, album.FolderIDs)
if err != nil {
return nil, nil, nil, err
}
@ -121,11 +118,21 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
folders = append(folders, *parent)
}
var paths []string
paths := slice.Map(folders, func(f model.Folder) string { return f.AbsolutePath() })
imgFiles, updatedAt := folderImages(folders)
return paths, imgFiles, &updatedAt, nil
}
func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
return ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
}
// folderImages collects the folders' image files, sorted so files without
// numeric suffixes win (e.g. cover.jpg over cover.1.jpg).
func folderImages(folders []model.Folder) ([]string, time.Time) {
var imgFiles []string
var updatedAt time.Time
for _, f := range folders {
paths = append(paths, f.AbsolutePath())
if f.ImagesUpdatedAt.After(updatedAt) {
updatedAt = f.ImagesUpdatedAt
}
@ -134,13 +141,8 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
imgFiles = append(imgFiles, path.Join(rel, img))
}
}
// Sort image files to ensure consistent selection of cover art
// This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg)
// by comparing base filenames without extensions
slices.SortFunc(imgFiles, compareImageFiles)
return paths, imgFiles, &updatedAt, nil
return imgFiles, updatedAt
}
// albumRootParent returns the common parent of the album's folders when it

View File

@ -3,6 +3,7 @@ package artwork
import (
"context"
"errors"
"path/filepath"
"time"
"github.com/navidrome/navidrome/model"
@ -339,6 +340,32 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(repo.getCallCount).To(Equal(1))
})
It("promotes the album root parent into the returned paths", func() {
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Artist",
Name: "Album",
ParentID: "artistFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
repo.parentResult = &model.Folder{
ID: "artistFolder",
Path: ".",
Name: "Artist",
ParentID: "libraryRoot",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"folder.jpg"},
}
paths, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(paths).To(Equal([]string{filepath.Join("Artist", "Album"), "Artist"}))
})
It("does not include parent images when other albums' audio lives under the parent", func() {
// Simulates: Artist/folder.jpg with Artist/Album (no images) and
// another album's tracks elsewhere under the artist folder

View File

@ -19,6 +19,7 @@ import (
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
@ -54,7 +55,7 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A
if err != nil {
return nil, err
}
albumPaths, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, als...)
albumPaths, imgFiles, imagesUpdatedAt, err := loadArtistAlbumRoots(ctx, artwork.ds, als)
if err != nil {
return nil, err
}
@ -232,17 +233,64 @@ func escapeGlobLiteral(s string) string {
return b.String()
}
// loadArtistAlbumRoots returns one path per album — the deepest folder holding
// all of that album's tracks — so an album split into disc subfolders can't
// pull the artist folder's common prefix below the artist level.
func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) {
var folderIDs []string
for _, album := range albums {
folderIDs = append(folderIDs, album.FolderIDs...)
}
folders, err := loadFolders(ctx, ds, folderIDs)
if err != nil {
return nil, nil, nil, err
}
pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) {
return f.ID, f.AbsolutePath()
})
var roots []string
for _, album := range albums {
var albumPaths []string
for _, fid := range album.FolderIDs {
if p, ok := pathByID[fid]; ok {
albumPaths = append(albumPaths, p)
}
}
if len(albumPaths) > 0 {
roots = append(roots, commonDir(albumPaths))
}
}
imgFiles, updatedAt := folderImages(folders)
return roots, imgFiles, &updatedAt, nil
}
// commonDir returns the deepest directory containing all paths. Trailing
// separators keep the comparison on segment boundaries, so a shared name
// fragment (".../Album" and ".../Album2") is never read as a shared directory.
func commonDir(paths []string) string {
sep := string(filepath.Separator)
common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep }))
if !strings.HasSuffix(common, sep) {
common, _ = filepath.Split(common)
}
return filepath.Clean(common)
}
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
if len(albums) == 0 {
return "", time.Time{}, nil
}
libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries
folderPath := str.LongestCommonPrefix(paths)
if !strings.HasSuffix(folderPath, string(filepath.Separator)) {
folderPath, _ = filepath.Split(folderPath)
// paths holds one root per album: two or more distinct roots already meet at
// the artist folder, while a single root is an album folder needing a climb.
roots := slices.Compact(slices.Sorted(slices.Values(paths)))
folderPath := commonDir(roots)
if len(roots) < 2 {
folderPath = filepath.Dir(folderPath)
}
folderPath = filepath.Dir(folderPath)
// Manipulate the path to get the folder ID
// TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM

View File

@ -85,6 +85,19 @@ var _ = Describe("artistArtworkReader", func() {
})
})
When("two albums share the same folder", func() {
It("climbs above the shared album folder", func() {
paths = []string{
filepath.FromSlash("/music/artist/split"),
filepath.FromSlash("/music/artist/split"),
}
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
Expect(err).ToNot(HaveOccurred())
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
Expect(upd).To(Equal(expectedUpdTime))
})
})
When("the album paths contain same prefix", func() {
It("returns the common prefix", func() {
paths = []string{

View File

@ -19,35 +19,41 @@ import (
)
var (
once sync.Once
once sync.Once
// TokenAuth signs UI/API session tokens. Rotated by the id migration so stale sessions die.
TokenAuth *jwtauth.JWTAuth
// PublicTokenAuth signs public-link tokens (artwork, share streams), on a separate secret that survives the rotation.
PublicTokenAuth *jwtauth.JWTAuth
)
// Init creates a JWTAuth object from the secret stored in the DB.
// If the secret is not found, it will create a new one and store it in the DB.
// Init creates the JWTAuth objects from the secrets stored in the DB.
// Missing or undecryptable secrets are regenerated and stored.
func Init(ds model.DataStore) {
once.Do(func() {
ctx := context.TODO()
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
secret, err := ds.Property(ctx).Get(consts.JWTSecretKey)
if err != nil || secret == "" {
log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions")
secret = createNewSecret(ctx, ds)
} else {
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err)
secret = createNewSecret(ctx, ds)
}
}
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
TokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTSecretKey)), nil)
PublicTokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTPublicSecretKey)), nil)
})
}
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
secret, err := ds.Property(ctx).Get(key)
if err != nil || secret == "" {
log.Info(ctx, "Creating new JWT secret", "key", key)
return createNewSecret(ctx, ds, key)
}
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err)
return createNewSecret(ctx, ds, key)
}
return secret
}
func CreatePublicToken(claims Claims) (string, error) {
claims.Issuer = consts.JWTIssuer
_, token, err := TokenAuth.Encode(claims.ToMap())
_, token, err := PublicTokenAuth.Encode(claims.ToMap())
return token, err
}
@ -56,7 +62,7 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
if !exp.IsZero() {
claims.ExpiresAt = exp
}
_, token, err := TokenAuth.Encode(claims.ToMap())
_, token, err := PublicTokenAuth.Encode(claims.ToMap())
return token, err
}
@ -91,6 +97,15 @@ func Validate(tokenStr string) (Claims, error) {
return ClaimsFromToken(token), nil
}
// ValidatePublic verifies a public-link token against the public secret.
func ValidatePublic(tokenStr string) (Claims, error) {
token, err := jwtauth.VerifyToken(PublicTokenAuth, tokenStr)
if err != nil {
return Claims{}, err
}
return ClaimsFromToken(token), nil
}
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
u, err := ds.User(ctx).FindFirstAdmin()
if err != nil {
@ -107,14 +122,14 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
return request.WithUser(ctx, *u)
}
func createNewSecret(ctx context.Context, ds model.DataStore) string {
func createNewSecret(ctx context.Context, ds model.DataStore, key string) string {
secret := id.NewRandom()
encSecret, err := utils.Encrypt(ctx, getEncKey(), secret)
if err != nil {
log.Error(ctx, "Could not encrypt JWT secret", err)
return secret
}
if err := ds.Property(ctx).Put(consts.JWTSecretKey, encSecret); err != nil {
if err := ds.Property(ctx).Put(key, encSecret); err != nil {
log.Error(ctx, "Could not save JWT secret in DB", err)
}
return secret

View File

@ -89,6 +89,51 @@ var _ = Describe("Auth", func() {
})
})
Describe("Session/Public secret split", func() {
claims := func() map[string]any {
return map[string]any{"iss": "issuer", "exp": time.Now().Add(1 * time.Minute).Unix()}
}
It("verifies a session token via Validate but not ValidatePublic", func() {
_, tokenStr, err := auth.TokenAuth.Encode(claims())
Expect(err).NotTo(HaveOccurred())
_, err = auth.Validate(tokenStr)
Expect(err).NotTo(HaveOccurred())
_, err = auth.ValidatePublic(tokenStr)
Expect(err).To(HaveOccurred())
})
It("verifies a public token via ValidatePublic but not Validate", func() {
_, tokenStr, err := auth.PublicTokenAuth.Encode(claims())
Expect(err).NotTo(HaveOccurred())
_, err = auth.ValidatePublic(tokenStr)
Expect(err).NotTo(HaveOccurred())
_, err = auth.Validate(tokenStr)
Expect(err).To(HaveOccurred())
})
It("decodes public tokens minted by CreatePublicToken via PublicTokenAuth", func() {
tokenStr, err := auth.CreatePublicToken(auth.Claims{ID: "art-1"})
Expect(err).NotTo(HaveOccurred())
claims, err := auth.ValidatePublic(tokenStr)
Expect(err).NotTo(HaveOccurred())
Expect(claims.ID).To(Equal("art-1"))
_, err = auth.Validate(tokenStr)
Expect(err).To(HaveOccurred())
})
It("decodes expiring public tokens minted by CreateExpiringPublicToken via PublicTokenAuth", func() {
exp := time.Now().Add(1 * time.Hour)
tokenStr, err := auth.CreateExpiringPublicToken(exp, auth.Claims{ID: "art-2"})
Expect(err).NotTo(HaveOccurred())
claims, err := auth.ValidatePublic(tokenStr)
Expect(err).NotTo(HaveOccurred())
Expect(claims.ID).To(Equal("art-2"))
_, err = auth.Validate(tokenStr)
Expect(err).To(HaveOccurred())
})
})
Describe("TouchToken", func() {
It("updates the expiration time", func() {
yesterday := time.Now().Add(-oneDay)

View File

@ -34,7 +34,7 @@ type Provider interface {
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error)
ArtistImage(ctx context.Context, id string) (*url.URL, error)
AlbumImage(ctx context.Context, id string) (*url.URL, error)
}
@ -440,11 +440,16 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
return url.Parse(img.URL)
}
func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
artist, err := e.findArtistByName(ctx, artistName)
func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) {
artist, err := e.findArtist(ctx, artistName, id)
if err != nil {
log.Error(ctx, "Artist not found", "name", artistName, err)
return nil, nil
if errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Artist not found", "name", artistName, "id", id, err)
return nil, nil
}
log.Error(ctx, "Failure occurred when trying to fetch artist", "name", artistName, "id", id, err)
return nil, err
}
songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count)
@ -713,7 +718,24 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
return matches, nil
}
func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) {
if id != "" {
artist, err := e.ds.Artist(ctx).Get(id)
if err == nil {
return &auxArtist{Artist: *artist}, nil
}
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Could not find artist by id", "id", id, err)
} else {
return nil, err
}
}
if artistName == "" {
return nil, model.ErrNotFound
}
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Like{"artist.name": artistName},
Max: 1,

View File

@ -65,7 +65,7 @@ var _ = Describe("Provider - TopSongs", func() {
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 2)
songs, err := p.TopSongs(ctx, "Artist One", "", 2)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(2))
@ -76,6 +76,59 @@ var _ = Describe("Provider - TopSongs", func() {
mediaFileRepo.AssertExpectations(GinkgoT())
})
Context("artist id and/or name", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
BeforeEach(func() {
agentSongs := []agents.Song{{Name: "Song One", MBID: "mbid-song-1"}}
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
})
It("returns top songs for a known artist by id only", func() {
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Once()
songs, err := p.TopSongs(ctx, "", "artist-1", 2)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
artistRepo.AssertExpectations(GinkgoT())
ag.AssertExpectations(GinkgoT())
mediaFileRepo.AssertExpectations(GinkgoT())
})
It("returns top songs for a known artist by id, falling back to name", func() {
artistRepo.On("Get", "fake-id").Return(nil, model.ErrNotFound).Once()
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", "fake-id", 2)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
artistRepo.AssertExpectations(GinkgoT())
ag.AssertExpectations(GinkgoT())
mediaFileRepo.AssertExpectations(GinkgoT())
})
})
It("bails if lookup by id returns a fatal error", func() {
artistRepo.On("Get", "artist-1").Return(nil, model.ErrInvalidAuth).Once()
songs, err := p.TopSongs(ctx, "Artist One", "artist-1", 2)
Expect(songs).To(BeEmpty())
Expect(err).To(HaveOccurred())
})
It("bails if lookup by name returns a fatal error", func() {
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(nil, model.ErrInvalidAuth).Once()
songs, err := p.TopSongs(ctx, "Artist One", "", 2)
Expect(songs).To(BeEmpty())
Expect(err).To(HaveOccurred())
})
It("backfills name and MBID onto an unnamed primary credit (the queried artist) and matches", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil)
@ -96,7 +149,7 @@ var _ = Describe("Provider - TopSongs", func() {
}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil)
songs, err := p.TopSongs(ctx, "Artist One", 1)
songs, err := p.TopSongs(ctx, "Artist One", "", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
@ -116,7 +169,8 @@ var _ = Describe("Provider - TopSongs", func() {
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
// Library track is credited to Artist One (the queried artist) under a same title. If the
// queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID
// queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID artistRepo.On("Get", "fake-id").Return(nil, model.ErrNotFound).Once()
// could mis-resolve. With the guard, "Artist Two" stays MBID-less and does not match One's track.
track := model.MediaFile{
ID: "one-track", Title: "Collab Song", ArtistID: "artist-1",
@ -126,7 +180,7 @@ var _ = Describe("Provider - TopSongs", func() {
}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil)
songs, err := p.TopSongs(ctx, "Artist One", 1)
songs, err := p.TopSongs(ctx, "Artist One", "", 1)
Expect(err).ToNot(HaveOccurred())
// "Artist Two" (named, MBID-less, not in the library) does not resolve to One's track.
@ -137,7 +191,7 @@ var _ = Describe("Provider - TopSongs", func() {
// Mock artist not found
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once()
songs, err := p.TopSongs(ctx, "Unknown Artist", 5)
songs, err := p.TopSongs(ctx, "Unknown Artist", "", 5)
Expect(err).ToNot(HaveOccurred()) // TopSongs returns nil error if artist not found
Expect(songs).To(BeNil())
@ -154,7 +208,7 @@ var _ = Describe("Provider - TopSongs", func() {
agentErr := errors.New("agent error")
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agentErr).Once()
songs, err := p.TopSongs(ctx, "Artist One", 5)
songs, err := p.TopSongs(ctx, "Artist One", "", 5)
Expect(err).To(MatchError(agentErr))
Expect(songs).To(BeNil())
@ -170,7 +224,7 @@ var _ = Describe("Provider - TopSongs", func() {
// Mock agent ErrNotFound
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agents.ErrNotFound).Once()
songs, err := p.TopSongs(ctx, "Artist One", 5)
songs, err := p.TopSongs(ctx, "Artist One", "", 5)
Expect(err).To(MatchError(model.ErrNotFound))
Expect(songs).To(BeNil())
@ -191,7 +245,7 @@ var _ = Describe("Provider - TopSongs", func() {
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 1)
songs, err := p.TopSongs(ctx, "Artist One", "", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
@ -220,7 +274,7 @@ var _ = Describe("Provider - TopSongs", func() {
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match
songs, err := p.TopSongs(ctx, "Artist One", 2)
songs, err := p.TopSongs(ctx, "Artist One", "", 2)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
@ -242,7 +296,7 @@ var _ = Describe("Provider - TopSongs", func() {
ag.On("GetArtistTopSongs", canceledCtx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, context.Canceled).Once()
cancel() // Cancel the context before calling
songs, err := p.TopSongs(canceledCtx, "Artist One", 5)
songs, err := p.TopSongs(canceledCtx, "Artist One", "", 5)
Expect(err).To(MatchError(context.Canceled))
Expect(songs).To(BeNil())
@ -276,7 +330,7 @@ var _ = Describe("Provider - TopSongs", func() {
}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 2)
songs, err := p.TopSongs(ctx, "Artist One", "", 2)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(2))
@ -313,7 +367,7 @@ var _ = Describe("Provider - TopSongs", func() {
}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 2)
songs, err := p.TopSongs(ctx, "Artist One", "", 2)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(2))
@ -341,7 +395,7 @@ var _ = Describe("Provider - TopSongs", func() {
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 1)
songs, err := p.TopSongs(ctx, "Artist One", "", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
@ -369,7 +423,7 @@ var _ = Describe("Provider - TopSongs", func() {
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 2)
songs, err := p.TopSongs(ctx, "Artist One", "", 2)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(2))
@ -397,7 +451,7 @@ var _ = Describe("Provider - TopSongs", func() {
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 1)
songs, err := p.TopSongs(ctx, "Artist One", "", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))

View File

@ -175,7 +175,7 @@ var _ = Describe("Public URL Utilities", func() {
BeforeEach(func() {
conf.Server.ShareURL = "https://share.example.com"
// Initialize JWT auth for token generation
auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
})
It("generates a URL with the artwork token", func() {

View File

@ -0,0 +1,310 @@
package migrations
import (
"context"
"crypto/md5"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/id"
"github.com/pressly/goose/v3"
)
// canonicalID maps any historical Navidrome id shape to the canonical 22-char base62 encoding
// of a 128-bit value; unrecognized shapes (including empty and share ids) pass through unchanged.
func canonicalID(s string) string {
switch len(s) {
case 22:
v, ok := new(big.Int).SetString(s, 62)
if !ok || v.Sign() < 0 || v.BitLen() <= 128 {
return s
}
sum := md5.Sum([]byte(s))
return id.Encode(sum)
case 32:
b, err := hex.DecodeString(s)
if err != nil {
return s
}
return id.Encode([16]byte(b))
case 36:
if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' {
return s
}
b, err := hex.DecodeString(s[:8] + s[9:13] + s[14:18] + s[19:23] + s[24:])
if err != nil {
return s
}
return id.Encode([16]byte(b))
}
return s
}
func init() {
goose.AddMigrationContext(upUniformCanonicalIds, downUniformCanonicalIds)
}
// idColumns is the exhaustive list of Navidrome-id-bearing columns; anything absent is deliberately exempt.
var idColumns = []struct{ table, col string }{
{"media_file", "id"}, {"media_file", "pid"}, {"media_file", "artist_id"},
{"media_file", "album_id"}, {"media_file", "album_artist_id"}, {"media_file", "folder_id"},
{"album", "id"}, {"album", "album_artist_id"},
{"artist", "id"},
{"folder", "id"}, {"folder", "parent_id"},
{"tag", "id"},
{"library_artist", "artist_id"},
{"user", "id"},
{"user_props", "user_id"},
{"playlist", "id"}, {"playlist", "owner_id"},
{"playlist_tracks", "playlist_id"}, {"playlist_tracks", "media_file_id"},
{"playlist_fields", "playlist_id"},
{"annotation", "user_id"}, {"annotation", "item_id"},
{"bookmark", "user_id"}, {"bookmark", "item_id"},
{"player", "id"}, {"player", "user_id"}, {"player", "transcoding_id"},
{"transcoding", "id"},
{"radio", "id"},
{"share", "user_id"},
{"scrobble_buffer", "id"}, {"scrobble_buffer", "user_id"}, {"scrobble_buffer", "media_file_id"},
{"playqueue", "id"}, {"playqueue", "user_id"},
{"user_library", "user_id"},
{"scrobbles", "user_id"}, {"scrobbles", "media_file_id"},
{"media_file_artists", "media_file_id"}, {"media_file_artists", "artist_id"},
{"album_artists", "album_id"}, {"album_artists", "artist_id"},
{"library_tag", "tag_id"},
}
// embeddedIDColumns holds ids nested inside a larger value; the id-columns guard checks this
// list against the schema, so every JSON column must appear here or be exempted there.
var embeddedIDColumns = []struct {
table, col string
transform func(string) (string, bool)
}{
{"playqueue", "items", canonicalizeIDList},
{"share", "resource_ids", canonicalizeIDList},
{"plugin", "users", canonicalizePluginUsers},
{"playlist", "rules", canonicalizePlaylistRules},
}
func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
if err := buildIDMap(ctx, tx); err != nil {
return err
}
for _, tc := range idColumns {
if err := applyIDMap(ctx, tx, tc.table, tc.col); err != nil {
return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err)
}
}
for _, tc := range embeddedIDColumns {
if err := rewriteColumn(ctx, tx, tc.table, tc.col, tc.transform); err != nil {
return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err)
}
}
// Legacy PID specs embed old-shaped album/track ids into composite pids; a full rescan
// rewrites every pid with the new encoding so path-based move matching stays consistent.
if strings.Contains(conf.Server.PID.Track, "legacy") || strings.Contains(conf.Server.PID.Album, "legacy") {
if err := forceFullRescan(ctx, tx); err != nil {
return err
}
}
if err := rotateSessionSecret(ctx, tx); err != nil {
return err
}
_, err := tx.ExecContext(ctx, "DROP TABLE _id_map")
return err
}
// rotateSessionSecret renames the session JWT secret to the public key, so public-link tokens
// keep verifying while auth.Init mints a fresh session secret, killing every stale session.
func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx,
`UPDATE property SET id = ? WHERE id = ? AND NOT EXISTS (SELECT 1 FROM property WHERE id = ?)`,
consts.JWTPublicSecretKey, consts.JWTSecretKey, consts.JWTPublicSecretKey)
return err
}
// buildIDMap stages old->new pairs for every id that changes, indexed for the update joins.
func buildIDMap(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx,
"CREATE TEMP TABLE _id_map (old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL) WITHOUT ROWID")
if err != nil {
return err
}
ins, err := tx.PrepareContext(ctx, "INSERT OR IGNORE INTO _id_map (old_id, new_id) VALUES (?, ?)")
if err != nil {
return err
}
defer ins.Close()
for _, tc := range idColumns {
if err := collectColumn(ctx, tx, ins, tc.table, tc.col); err != nil {
return fmt.Errorf("collecting %s.%s: %w", tc.table, tc.col, err)
}
}
return nil
}
func collectColumn(ctx context.Context, tx *sql.Tx, ins *sql.Stmt, table, col string) error {
rows, err := tx.QueryContext(ctx, fmt.Sprintf(
"SELECT DISTINCT %[2]s FROM %[1]s WHERE %[2]s IS NOT NULL AND %[2]s <> ''", table, col))
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var old string
if err := rows.Scan(&old); err != nil {
return err
}
if newID := canonicalID(old); newID != old {
if _, err := ins.ExecContext(ctx, old, newID); err != nil {
return err
}
}
}
return rows.Err()
}
func applyIDMap(ctx context.Context, tx *sql.Tx, table, col string) error {
_, err := tx.ExecContext(ctx, fmt.Sprintf(
`UPDATE %[1]s SET %[2]s = (SELECT new_id FROM _id_map WHERE old_id = %[1]s.%[2]s)
WHERE %[2]s IN (SELECT old_id FROM _id_map)`, table, col))
return err
}
// canonicalizeIDList canonicalizes a comma-separated id list element-wise.
func canonicalizeIDList(s string) (string, bool) {
parts := strings.Split(s, ",")
changed := false
for i, p := range parts {
if n := canonicalID(p); n != p {
parts[i] = n
changed = true
}
}
if !changed {
return s, false
}
return strings.Join(parts, ","), true
}
// rewriteColumn applies transform to each non-empty cell, updating only rows it changed.
func rewriteColumn(ctx context.Context, tx *sql.Tx, table, col string, transform func(string) (string, bool)) error {
rows, err := tx.QueryContext(ctx, fmt.Sprintf(
"SELECT rowid, %[2]s FROM %[1]s WHERE ifnull(%[2]s, '') <> ''", table, col))
if err != nil {
return err
}
type change struct {
rowid int64
val string
}
var changes []change
for rows.Next() {
var rowid int64
var val string
if err := rows.Scan(&rowid, &val); err != nil {
_ = rows.Close()
return err
}
if newVal, ok := transform(val); ok {
changes = append(changes, change{rowid, newVal})
}
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return err
}
_ = rows.Close()
if len(changes) == 0 {
return nil
}
upd, err := tx.PrepareContext(ctx, fmt.Sprintf("UPDATE %s SET %s = ? WHERE rowid = ?", table, col))
if err != nil {
return err
}
defer upd.Close()
for _, c := range changes {
if _, err := upd.ExecContext(ctx, c.val, c.rowid); err != nil {
return err
}
}
return nil
}
// canonicalizePluginUsers maps a JSON array of user ids; malformed JSON passes through untouched.
func canonicalizePluginUsers(s string) (string, bool) {
var users []string
if err := json.Unmarshal([]byte(s), &users); err != nil {
return s, false
}
changed := false
for i, u := range users {
if n := canonicalID(u); n != u {
users[i] = n
changed = true
}
}
if !changed {
return s, false
}
out, err := json.Marshal(users)
if err != nil {
return s, false
}
return string(out), true
}
// canonicalizePlaylistRules rewrites inPlaylist/notInPlaylist ids in smart-playlist criteria; malformed JSON passes through.
func canonicalizePlaylistRules(s string) (string, bool) {
var root map[string]any
if err := json.Unmarshal([]byte(s), &root); err != nil {
return s, false
}
if !canonicalizeRulesNode(root) {
return s, false
}
out, err := json.Marshal(root)
if err != nil {
return s, false
}
return string(out), true
}
// canonicalizeRulesNode walks the criteria tree, canonicalizing the id of any inPlaylist/notInPlaylist object.
func canonicalizeRulesNode(node any) bool {
changed := false
switch v := node.(type) {
case map[string]any:
for k, val := range v {
if lk := strings.ToLower(k); lk == "inplaylist" || lk == "notinplaylist" {
if obj, ok := val.(map[string]any); ok {
if id, ok := obj["id"].(string); ok {
if n := canonicalID(id); n != id {
obj["id"] = n
changed = true
}
}
}
}
if canonicalizeRulesNode(val) {
changed = true
}
}
case []any:
for _, item := range v {
if canonicalizeRulesNode(item) {
changed = true
}
}
}
return changed
}
func downUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
return nil // irreversible data migration
}

View File

@ -0,0 +1,47 @@
package migrations
import (
"strings"
"github.com/navidrome/navidrome/model/id"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("canonicalID", func() {
DescribeTable("transforms each historical id shape",
func(in, want string) {
Expect(canonicalID(in)).To(Equal(want))
},
Entry("hash-family id (fits 128 bits) is kept", "5cLJPkLA5DK2BADhoeotPk", "5cLJPkLA5DK2BADhoeotPk"),
Entry("overflowing random id is remapped via md5", "zzzzzzzzzzzzzzzzzzzzzz", "3LyqmwQBm5IRqlVjNYASwb"),
Entry("legacy 32-hex is re-encoded value-preserving", "e3b7fc2ae9447bbec37a13bf916e3cf6", "6VHl3uR4kss6sUPKA8Cwnk"),
Entry("playlist uuid is re-encoded value-preserving", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "7rke2SAWaicSeSYzkhww6R"),
Entry("empty string passes through", "", ""),
Entry("share id (10 chars) passes through", "aB3xY9kQz1", "aB3xY9kQz1"),
Entry("truncated Finamp id (16 chars) passes through", "0123456789abcdef", "0123456789abcdef"),
Entry("22 chars with non-base62 char passes through", "!!!!!!!!!!!!!!!!!!!!!!", "!!!!!!!!!!!!!!!!!!!!!!"),
Entry("32 chars non-hex passes through", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"),
Entry("36 chars without uuid dashes passes through", "000000000000000000000000000000000000", "000000000000000000000000000000000000"),
)
// The exemptions for participants/tags/folder_ids/similar_artists rest on this invariant.
It("is the identity on every NewHash id", func() {
for _, parts := range [][]string{
{""}, {"a"}, {"The Beatles"}, {"genre", "electronic"},
{"/music/Artist/Album", "1"}, {strings.Repeat("x", 500)},
} {
h := id.NewHash(parts...)
Expect(h).To(HaveLen(22))
Expect(canonicalID(h)).To(Equal(h), "NewHash(%v) = %q was rewritten", parts, h)
}
})
It("is idempotent for every shape", func() {
for _, s := range []string{"5cLJPkLA5DK2BADhoeotPk", "zzzzzzzzzzzzzzzzzzzzzz",
"e3b7fc2ae9447bbec37a13bf916e3cf6", "f47ac10b-58cc-4372-a567-0e02b2c3d479"} {
once := canonicalID(s)
Expect(canonicalID(once)).To(Equal(once))
}
})
})

View File

@ -0,0 +1,119 @@
package migrations
import (
"context"
"database/sql"
"os"
"strings"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Version of the uniform-canonical-ids migration whose idColumns list this guard protects.
const idColumnsMigrationVersion = 20260720015443
var _ = Describe("idColumns inventory", func() {
It("covers every id-bearing column present at the migration version", func() {
ctx := context.Background()
db, err := sql.Open("sqlite3", "file::memory:")
Expect(err).ToNot(HaveOccurred())
db.SetMaxOpenConns(1) // non-shared :memory: — a second conn would be an empty DB
DeferCleanup(func() { _ = db.Close() })
_, err = db.ExecContext(ctx, "PRAGMA foreign_keys=off")
Expect(err).ToNot(HaveOccurred())
goose.SetBaseFS(os.DirFS("."))
DeferCleanup(func() { goose.SetBaseFS(nil) })
Expect(goose.SetDialect("sqlite3")).To(Succeed())
Expect(goose.UpToContext(ctx, db, ".", idColumnsMigrationVersion)).To(Succeed())
covered := map[string]bool{}
for _, tc := range idColumns {
covered[tc.table+"."+tc.col] = true
}
for _, tc := range embeddedIDColumns {
covered[tc.table+"."+tc.col] = true
}
// Columns that are id-named or JSON but need no rewrite.
exempt := map[string]string{
"share.id": "public share URLs, generated separately",
"property.id": "property key, not an entity id",
"plugin.id": "plugin name, not an entity id",
"media_file.participants": "hash-family artist ids, unchanged",
"album.participants": "hash-family artist ids, unchanged",
"media_file.tags": "hash-family tag ids, unchanged",
"album.tags": "hash-family tag ids, unchanged",
"album.folder_ids": "hash-family folder ids, unchanged",
"artist.similar_artists": "hash-family artist ids, unchanged",
"media_file.search_participants": "participant names for FTS, not ids",
"album.search_participants": "participant names for FTS, not ids",
"album.discs": "disc number -> title map",
"media_file.lyrics": "synced lyrics, no ids",
"folder.image_files": "image file names, no ids",
"plugin.manifest": "plugin-authored manifest, no Navidrome ids",
"plugin.config": "free-form plugin config, must not be rewritten",
"plugin.libraries": "integer library ids",
}
tables, err := queryColumn(ctx, db, "SELECT name FROM sqlite_master WHERE type='table'")
Expect(err).ToNot(HaveOccurred())
var dangling []string
for _, table := range tables {
if strings.HasPrefix(table, "sqlite_") || table == "goose_db_version" || strings.Contains(table, "_fts") {
continue
}
rows, err := db.QueryContext(ctx, "SELECT name, type FROM pragma_table_info(?)", table)
Expect(err).ToNot(HaveOccurred())
for rows.Next() {
var name, typ string
Expect(rows.Scan(&name, &typ)).To(Succeed())
lname := strings.ToLower(name)
utyp := strings.ToUpper(typ)
// JSON can hide an id under any key, so every JSON column needs a verdict.
isJSON := strings.Contains(utyp, "JSON")
isIDName := lname == "id" || lname == "pid" ||
strings.HasSuffix(lname, "_id") || strings.HasSuffix(lname, "_ids")
if !isJSON && !isIDName {
continue
}
if !isJSON && !strings.Contains(utyp, "TEXT") && !strings.Contains(utyp, "CHAR") {
continue // INTEGER ids (rowid PKs, library.id) are not canonical ids
}
if strings.HasPrefix(lname, "mbz_") {
continue // MusicBrainz UUIDs, not Navidrome ids
}
key := table + "." + name
if covered[key] || exempt[key] != "" {
continue
}
dangling = append(dangling, key)
}
Expect(rows.Err()).ToNot(HaveOccurred())
Expect(rows.Close()).To(Succeed())
}
Expect(dangling).To(BeEmpty(),
"id-bearing columns missing from idColumns; add them to the migration or exempt with a reason: %v", dangling)
})
})
func queryColumn(ctx context.Context, db *sql.DB, query string) ([]string, error) {
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}

View File

@ -0,0 +1,16 @@
package migrations
import (
"testing"
"github.com/navidrome/navidrome/log"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// tests.Init is omitted: the tests package imports db, which imports this package.
func TestMigrations(t *testing.T) {
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Migrations Suite")
}

View File

@ -0,0 +1,233 @@
package migrations
import (
"context"
"database/sql"
"encoding/json"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("upUniformCanonicalIds", func() {
var db *sql.DB
var tx *sql.Tx
ctx := context.Background()
const (
hashID = "5cLJPkLA5DK2BADhoeotPk" // canonical, kept
randOld = "zzzzzzzzzzzzzzzzzzzzzz" // overflows -> remapped
randNew = "3LyqmwQBm5IRqlVjNYASwb"
legacyOld = "e3b7fc2ae9447bbec37a13bf916e3cf6" // 32-hex -> re-encoded
legacyNew = "6VHl3uR4kss6sUPKA8Cwnk"
uuidOld = "f47ac10b-58cc-4372-a567-0e02b2c3d479" // uuid -> re-encoded
uuidNew = "7rke2SAWaicSeSYzkhww6R"
shareID = "aB3xY9kQz1" // exempt family
sessionSecret = "encrypted-session-secret-sentinel"
)
BeforeEach(func() {
var err error
db, err = sql.Open("sqlite3", "file::memory:")
Expect(err).ToNot(HaveOccurred())
db.SetMaxOpenConns(1) // non-shared :memory: — every new conn is a fresh empty DB
DeferCleanup(func() { _ = db.Close() })
// Minimal fixture: every table/column idColumns and the list rewrites touch.
_, err = db.Exec(`
CREATE TABLE media_file (id text, pid text, artist_id text, album_id text, album_artist_id text, folder_id text, mbz_recording_id text);
CREATE TABLE album (id text, album_artist_id text);
CREATE TABLE artist (id text);
CREATE TABLE folder (id text, parent_id text);
CREATE TABLE tag (id text);
CREATE TABLE library_artist (artist_id text);
CREATE TABLE user (id text);
CREATE TABLE user_props (user_id text);
CREATE TABLE playlist (id text, owner_id text, rules text);
CREATE TABLE playlist_tracks (playlist_id text, media_file_id text);
CREATE TABLE playlist_fields (playlist_id text);
CREATE TABLE annotation (user_id text, item_id text, item_type text);
CREATE TABLE bookmark (user_id text, item_id text);
CREATE TABLE player (id text, user_id text, transcoding_id text);
CREATE TABLE transcoding (id text);
CREATE TABLE radio (id text);
CREATE TABLE share (id text, user_id text, resource_ids text, contents text);
CREATE TABLE scrobble_buffer (id text, user_id text, media_file_id text);
CREATE TABLE playqueue (id text, user_id text, items text);
CREATE TABLE user_library (user_id text, library_id integer);
CREATE TABLE scrobbles (id integer, media_file_id text, user_id text);
CREATE TABLE media_file_artists (media_file_id text, artist_id text);
CREATE TABLE album_artists (album_id text, artist_id text);
CREATE TABLE library_tag (tag_id text, library_id integer);
CREATE TABLE plugin (id text, users text);
CREATE TABLE property (id text primary key, value text);
`)
Expect(err).ToNot(HaveOccurred())
seed := func(query string, args ...any) {
_, err := db.Exec(query, args...)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
}
// media_file: legacy id/pid, hash artist, legacy album, mbz uuid must stay untouched
seed(`INSERT INTO media_file VALUES (?, ?, ?, ?, '', '', ?)`, legacyOld, legacyOld, hashID, legacyOld, uuidOld)
seed(`INSERT INTO album VALUES (?, ?)`, legacyOld, hashID)
seed(`INSERT INTO user VALUES (?)`, randOld)
// uuid id, random owner, smart-playlist rules with an embedded inPlaylist id and a sibling operator
seed(`INSERT INTO playlist VALUES (?, ?, ?)`, uuidOld, randOld, `{"all":[{"inPlaylist":{"id":"`+uuidOld+`"}},{"inTheLast":{"lastPlayed":30}}]}`)
seed(`INSERT INTO annotation VALUES (?, ?, 'media_file')`, randOld, legacyOld)
seed(`INSERT INTO playqueue VALUES (?, ?, ?)`, randOld, randOld, legacyOld+","+hashID)
seed(`INSERT INTO share VALUES (?, ?, ?, 'Album Foo...')`, shareID, randOld, legacyOld+","+uuidOld)
seed(`INSERT INTO user_library VALUES (?, 1)`, randOld)
seed(`INSERT INTO scrobbles VALUES (1, ?, ?)`, legacyOld, randOld)
seed(`INSERT INTO media_file_artists VALUES (?, ?)`, legacyOld, hashID)
seed(`INSERT INTO album_artists VALUES (?, ?)`, legacyOld, hashID)
seed(`INSERT INTO library_tag VALUES (?, 1)`, hashID)
seed(`INSERT INTO plugin VALUES ('lastfm', ?)`, `["`+randOld+`","`+hashID+`"]`)
seed(`INSERT INTO plugin VALUES ('empty', '[]')`) // exempt: empty user list untouched
// malformed JSON in both a plugin list and a playlist rule: must pass through byte-for-byte
seed(`INSERT INTO plugin VALUES ('broken', 'not-json')`)
seed(`INSERT INTO playlist VALUES (?, ?, '{broken')`, hashID, hashID)
seed(`INSERT INTO property VALUES (?, ?)`, consts.JWTSecretKey, sessionSecret)
})
JustBeforeEach(func() {
var err error
tx, err = db.Begin()
Expect(err).ToNot(HaveOccurred())
Expect(upUniformCanonicalIds(ctx, tx)).To(Succeed())
Expect(tx.Commit()).To(Succeed())
})
get := func(query string) string {
var s string
ExpectWithOffset(1, db.QueryRow(query).Scan(&s)).To(Succeed())
return s
}
It("canonicalizes every id family and keeps references consistent", func() {
Expect(get(`SELECT id FROM media_file`)).To(Equal(legacyNew))
Expect(get(`SELECT pid FROM media_file`)).To(Equal(legacyNew))
Expect(get(`SELECT artist_id FROM media_file`)).To(Equal(hashID))
Expect(get(`SELECT album_id FROM media_file`)).To(Equal(legacyNew))
Expect(get(`SELECT id FROM album`)).To(Equal(legacyNew))
Expect(get(`SELECT id FROM user`)).To(Equal(randNew))
Expect(get(`SELECT id FROM playlist WHERE owner_id='` + randNew + `'`)).To(Equal(uuidNew))
Expect(get(`SELECT owner_id FROM playlist WHERE id='` + uuidNew + `'`)).To(Equal(randNew))
Expect(get(`SELECT item_id FROM annotation`)).To(Equal(legacyNew))
Expect(get(`SELECT user_id FROM annotation`)).To(Equal(randNew))
})
It("rewrites list columns element-wise", func() {
Expect(get(`SELECT items FROM playqueue`)).To(Equal(legacyNew + "," + hashID))
Expect(get(`SELECT resource_ids FROM share`)).To(Equal(legacyNew + "," + uuidNew))
})
It("leaves exempt columns alone", func() {
Expect(get(`SELECT id FROM share`)).To(Equal(shareID))
Expect(get(`SELECT contents FROM share`)).To(Equal("Album Foo..."))
Expect(get(`SELECT mbz_recording_id FROM media_file`)).To(Equal(uuidOld))
})
It("canonicalizes junction and membership tables", func() {
Expect(get(`SELECT user_id FROM user_library`)).To(Equal(randNew))
Expect(get(`SELECT media_file_id FROM scrobbles`)).To(Equal(legacyNew))
Expect(get(`SELECT user_id FROM scrobbles`)).To(Equal(randNew))
Expect(get(`SELECT media_file_id FROM media_file_artists`)).To(Equal(legacyNew))
Expect(get(`SELECT artist_id FROM media_file_artists`)).To(Equal(hashID)) // hash-family, unchanged
Expect(get(`SELECT album_id FROM album_artists`)).To(Equal(legacyNew))
Expect(get(`SELECT artist_id FROM album_artists`)).To(Equal(hashID)) // hash-family, unchanged
Expect(get(`SELECT tag_id FROM library_tag`)).To(Equal(hashID)) // hash-family, unchanged
})
It("rewrites JSON columns element-wise", func() {
Expect(get(`SELECT users FROM plugin WHERE id='lastfm'`)).To(Equal(`["` + randNew + `","` + hashID + `"]`))
Expect(get(`SELECT id FROM plugin WHERE id='lastfm'`)).To(Equal("lastfm")) // plugin name, untouched
var rules map[string]any
Expect(json.Unmarshal([]byte(get(`SELECT rules FROM playlist WHERE id='`+uuidNew+`'`)), &rules)).To(Succeed())
all, ok := rules["all"].([]any)
Expect(ok).To(BeTrue())
Expect(all).To(HaveLen(2))
inPl := all[0].(map[string]any)["inPlaylist"].(map[string]any)
Expect(inPl["id"]).To(Equal(uuidNew))
})
It("preserves sibling operators alongside a rewritten inPlaylist id", func() {
var rules map[string]any
Expect(json.Unmarshal([]byte(get(`SELECT rules FROM playlist WHERE id='`+uuidNew+`'`)), &rules)).To(Succeed())
all := rules["all"].([]any)
var sawInPlaylist, sawInTheLast bool
for _, e := range all {
op := e.(map[string]any)
if pl, ok := op["inPlaylist"].(map[string]any); ok {
Expect(pl["id"]).To(Equal(uuidNew))
sawInPlaylist = true
}
if last, ok := op["inTheLast"].(map[string]any); ok {
Expect(last["lastPlayed"]).To(Equal(float64(30)))
sawInTheLast = true
}
}
Expect(sawInPlaylist).To(BeTrue())
Expect(sawInTheLast).To(BeTrue())
})
It("leaves exempt JSON rows untouched", func() {
Expect(get(`SELECT users FROM plugin WHERE id='empty'`)).To(Equal("[]"))
})
It("passes malformed JSON columns through byte-for-byte", func() {
Expect(get(`SELECT users FROM plugin WHERE id='broken'`)).To(Equal("not-json"))
Expect(get(`SELECT rules FROM playlist WHERE id='` + hashID + `'`)).To(Equal("{broken"))
})
rescanCount := func() int {
var count int
ExpectWithOffset(1, db.QueryRow(
`SELECT count(*) FROM property WHERE id = ?`, consts.FullScanAfterMigrationFlagKey).Scan(&count)).To(Succeed())
return count
}
It("does not force a full rescan for the default PID config", func() {
Expect(rescanCount()).To(Equal(0))
})
Context("with a legacy PID configuration", func() {
BeforeEach(func() {
prev := conf.Server.PID.Album
conf.Server.PID.Album = "album_legacy"
DeferCleanup(func() { conf.Server.PID.Album = prev })
})
It("forces a full rescan so composite pids are rewritten", func() {
Expect(rescanCount()).To(Equal(1))
})
})
propCount := func(key string) int {
var count int
ExpectWithOffset(1, db.QueryRow(`SELECT count(*) FROM property WHERE id = ?`, key).Scan(&count)).To(Succeed())
return count
}
It("rotates the session secret to the public key", func() {
Expect(propCount(consts.JWTSecretKey)).To(Equal(0))
Expect(get(`SELECT value FROM property WHERE id = '` + consts.JWTPublicSecretKey + `'`)).To(Equal(sessionSecret))
})
Context("with no stored session secret", func() {
BeforeEach(func() {
_, err := db.Exec(`DELETE FROM property WHERE id = ?`, consts.JWTSecretKey)
Expect(err).ToNot(HaveOccurred())
})
It("is a no-op", func() {
Expect(propCount(consts.JWTSecretKey)).To(Equal(0))
Expect(propCount(consts.JWTPublicSecretKey)).To(Equal(0))
})
})
})

View File

@ -2,20 +2,36 @@ package id
import (
"crypto/md5"
"crypto/rand"
"fmt"
"math/big"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/nanoid"
)
func NewRandom() string {
id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22)
if err != nil {
log.Error("Could not generate new ID", err)
var b [16]byte
_, _ = rand.Read(b[:])
return Encode(b)
}
// Encode renders a 16-byte value as the canonical 22-char zero-padded base62 id.
func Encode(b [16]byte) string {
return fmt.Sprintf("%022s", new(big.Int).SetBytes(b[:]).Text(62))
}
// Decode is the exact inverse of Encode.
func Decode(s string) ([]byte, error) {
if len(s) != 22 {
return nil, fmt.Errorf("invalid id length %d", len(s))
}
return id
v, ok := new(big.Int).SetString(s, 62)
if !ok || v.Sign() < 0 {
return nil, fmt.Errorf("invalid base62 id %q", s)
}
if v.BitLen() > 128 {
return nil, fmt.Errorf("id %q overflows 128 bits", s)
}
return v.FillBytes(make([]byte, 16)), nil
}
func NewHash(data ...string) string {
@ -24,11 +40,7 @@ func NewHash(data ...string) string {
hash.Write([]byte(d))
hash.Write([]byte(string('\u200b')))
}
h := hash.Sum(nil)
bi := big.NewInt(0)
bi.SetBytes(h)
s := bi.Text(62)
return fmt.Sprintf("%022s", s)
return Encode([16]byte(hash.Sum(nil)))
}
func NewTagID(name, value string) string {

17
model/id/id_suite_test.go Normal file
View File

@ -0,0 +1,17 @@
package id_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestID(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "ID Suite")
}

64
model/id/id_test.go Normal file
View File

@ -0,0 +1,64 @@
package id_test
import (
"github.com/navidrome/navidrome/model/id"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Encode/Decode", func() {
It("encodes 16 bytes as 22-char zero-padded base62", func() {
Expect(id.Encode([16]byte{})).To(Equal("0000000000000000000000"))
allFF := [16]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
Expect(id.Encode(allFF)).To(Equal("7N42dgm5tFLK9N8MT7fHC7"))
})
It("round-trips arbitrary 16-byte values", func() {
b := [16]byte{0xe3, 0xb7, 0xfc, 0x2a, 0xe9, 0x44, 0x7b, 0xbe,
0xc3, 0x7a, 0x13, 0xbf, 0x91, 0x6e, 0x3c, 0xf6}
s := id.Encode(b)
Expect(s).To(Equal("6VHl3uR4kss6sUPKA8Cwnk"))
Expect(id.Decode(s)).To(Equal(b[:]))
})
It("rejects invalid input", func() {
_, err := id.Decode("short")
Expect(err).To(HaveOccurred())
_, err = id.Decode("!!!!!!!!!!!!!!!!!!!!!!") // 22 chars, not base62
Expect(err).To(HaveOccurred())
_, err = id.Decode("-000000000000000000001") // sign is not part of the alphabet
Expect(err).To(HaveOccurred())
_, err = id.Decode("zzzzzzzzzzzzzzzzzzzzzz") // > 2^128
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("NewRandom", func() {
It("emits 22-char canonical ids that always fit 128 bits", func() {
seen := make(map[string]struct{})
for range 1000 {
s := id.NewRandom()
Expect(s).To(HaveLen(22))
_, err := id.Decode(s)
Expect(err).ToNot(HaveOccurred(), "id %q must decode to 128 bits", s)
seen[s] = struct{}{}
}
Expect(seen).To(HaveLen(1000))
})
})
var _ = Describe("NewHash", func() {
It("keeps its historical output byte-for-byte (golden)", func() {
Expect(id.NewHash("test")).To(Equal("5cLJPkLA5DK2BADhoeotPk"))
Expect(id.NewHash("[unknown artist]")).To(Equal("7lsE5pS09fPS1VuFqwXbia"))
Expect(id.NewTagID("genre", "electronic")).To(Equal("7bLYq0Np81m1Wgy5N31nuG"))
})
It("always emits 22 decodable chars", func() {
h := id.NewHash("anything", "at", "all")
Expect(h).To(HaveLen(22))
_, err := id.Decode(h)
Expect(err).ToNot(HaveOccurred())
})
})

View File

@ -9,17 +9,19 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
// These are the legacy ID functions that were used in the original Navidrome ID generation.
// They are kept here for backwards compatibility with existing databases.
// These legacy ID functions hash the same inputs as the original Navidrome ID generation,
// now emitted in the canonical base62 encoding (matching what the uniform-ids migration stores).
func legacyTrackID(mf model.MediaFile, prependLibId bool) string {
id := mf.Path
key := mf.Path
if prependLibId && mf.LibraryID != model.DefaultLibraryID {
id = fmt.Sprintf("%d\\%s", mf.LibraryID, id)
key = fmt.Sprintf("%d\\%s", mf.LibraryID, key)
}
return fmt.Sprintf("%x", md5.Sum([]byte(id)))
sum := md5.Sum([]byte(key))
return id.Encode(sum)
}
func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string {
@ -33,7 +35,8 @@ func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string {
if prependLibId && mf.LibraryID != model.DefaultLibraryID {
albumPath = fmt.Sprintf("%d\\%s", mf.LibraryID, albumPath)
}
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
sum := md5.Sum([]byte(albumPath))
return id.Encode(sum)
}
func legacyMapAlbumArtistName(md Metadata) string {

View File

@ -218,6 +218,21 @@ var _ = Describe("getPID", func() {
})
Context("legacy specs", func() {
It("emits canonical 22-char base62 ids", func() {
mf := model.MediaFile{Path: "/music/a.mp3", LibraryID: 1}
// md5("/music/a.mp3") = e3b7fc2ae9447bbec37a13bf916e3cf6 re-encoded as base62
Expect(legacyTrackID(mf, false)).To(Equal("6VHl3uR4kss6sUPKA8Cwnk"))
})
It("prepends the library id for a non-default library", func() {
mf := model.MediaFile{Path: "/music/a.mp3", LibraryID: 2}
// id.Encode(md5.Sum([]byte("2\\/music/a.mp3")))
Expect(legacyTrackID(mf, true)).To(Equal("4EK5DHQBMeFuDHw6S3iooO"))
})
It("emits a canonical album id (golden)", func() {
mf := model.MediaFile{LibraryID: 1}
// id.Encode(md5.Sum([]byte("[unknown artist]\\[unknown album]")))
Expect(legacyAlbumID(mf, Metadata{}, false)).To(Equal("6xBmxSAUFJSQuW7UvwCq8X"))
})
Context("track_legacy", func() {
When("library ID is default (1)", func() {
It("should not prepend library ID even when prependLibId is true", func() {

View File

@ -30,6 +30,7 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag
- [Scheduler](#scheduler)
- [Cache](#cache)
- [KVStore](#kvstore)
- [Storage](#storage)
- [Task](#task)
- [WebSocket](#websocket)
- [Library](#library)
@ -548,6 +549,52 @@ usage, err := host.KVStoreGetStorageUsed()
fmt.Printf("Using %d bytes\n", usage)
```
### Storage
A private read-write directory, mounted into the sandbox at `/storage` and backed by `${DataFolder}/plugins/${pluginID}/storage`. Survives server restarts. Use it for data that doesn't fit a key-value store: caches, downloaded files, generated indexes.
**Manifest permission:**
```json
{
"permissions": {
"storage": {
"reason": "Cache generated playlists between restarts"
}
}
}
```
**Host functions:**
| Function | Parameters | Description |
|--------------------------|------------|------------------------------------|
| `storage_getstoragepath` | – | Get the guest path of the mount |
**Usage:**
Normal WASI filesystem calls work inside the mount, so use the `os` package directly:
```go
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
)
// The path never changes, so read it once instead of per operation
var storageDir = host.StorageGetStoragePath() // "/storage"
err := os.WriteFile(filepath.Join(storageDir, "cache.json"), data, 0600)
content, err := os.ReadFile(filepath.Join(storageDir, "cache.json"))
entries, err := os.ReadDir(storageDir)
```
> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks that already exist in the directory are still followed, so anything linked in from elsewhere remains reachable.
> **Note:** There is no size limit, unlike [KVStore](#kvstore). The directory is not deleted when a plugin is uninstalled.
### Task
Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function.
@ -644,7 +691,7 @@ Access music library metadata and optionally read files from library directories
}
```
- `filesystem` – Set to `true` to enable read-only access to library directories (default: `false`)
- `filesystem` – Set to `true` to enable access to library directories, read-only unless an administrator grants write access (default: `false`)
**Host functions:**
@ -683,7 +730,7 @@ content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
entries, err := os.ReadDir("/libraries/1/Artist")
```
> **Security:** Filesystem access is read-only and restricted to configured library paths only.
> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks already present in the library are still followed, so folders linked in from elsewhere work as expected. Access is read-only unless an administrator grants the plugin write access (`navidrome plugin edit <name> --write-access`).
**Usage:**
@ -1058,7 +1105,7 @@ See [examples/](examples/) for complete working plugins:
Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime:
1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket
2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission
2. **Limited File System** – Plugins cannot create symlinks inside a mount and `..` or absolute paths are rejected, though symlinks already present are followed. Library access requires the `library.filesystem` permission and is read-only unless an administrator grants write access; the `storage` permission grants a read-write directory private to the plugin
3. **No Network Listeners** – Plugins cannot bind ports
4. **Config Isolation** – Plugins only receive their own config section
5. **Memory Limits** – Controlled by the WebAssembly runtime

View File

@ -9,6 +9,10 @@ type ScrobbleList struct {
Scrobbles []ScrobbleRef `json:"scrobbles"`
// If additional items are available, the timestamp of the next scrobble to fetch
NextTimestamp *int64 `json:"nextTimestamp,omitempty"`
// In the event that multiple scrobbles were submitted at the same time, cursor
// will be populated to enable future queries. If this cursor is nonero
// it should be passed on for the next request
Cursor int `json:"cursor,omitempty"`
}
// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time)
@ -29,6 +33,12 @@ type ScrobbleOptions struct {
// The ending unix timestamp to query for scrobbles (inclusive).
// If not specified, go up to the last scrobble
ToTimestamp *int64 `json:"toTimestamp,omitempty"`
// Descending is only relevant if both FromTimestamp and ToTimestamp are specified
// If both timestamps are specified (or omitted), the default order will be ascending submission time
// If Descending is true, instead query in descending order
Descending bool `json:"descending"`
// A cursor retrieved from a prior call to GetScrobbles
Cursor int `json:"cursor,omitempty"`
// The maximum number of items to retrieve. This is capped at 5000, the
// default if not specified
MaxItems int `json:"maxItems"`

13
plugins/host/storage.go Normal file
View File

@ -0,0 +1,13 @@
package host
import "context"
// StorageService provides access to a plugin-specific directory with read/write permissions
//
//nd:hostservice name=Storage permission=storage
type StorageService interface {
// GetStoragePath retrieves the persistent storage path, if allowed
//
//nd:hostfunc
GetStoragePath(ctx context.Context) string
}

View File

@ -0,0 +1,67 @@
// Code generated by ndpgen. DO NOT EDIT.
package host
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// StorageGetStoragePathResponse is the response type for Storage.GetStoragePath.
type StorageGetStoragePathResponse struct {
Result string `json:"result,omitempty"`
}
// RegisterStorageHostFunctions registers Storage service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterStorageHostFunctions(service StorageService) []extism.HostFunction {
return []extism.HostFunction{
newStorageGetStoragePathHostFunction(service),
}
}
func newStorageGetStoragePathHostFunction(service StorageService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"storage_getstoragepath",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Call the service method
result := service.GetStoragePath(ctx)
// Write JSON response to plugin memory
resp := StorageGetStoragePathResponse{
Result: result,
}
storageWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// storageWriteResponse writes a JSON response to plugin memory.
func storageWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
storageWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// storageWriteError writes an error response to plugin memory.
func storageWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

View File

@ -225,7 +225,7 @@ func decodeArtworkURL(artworkURL string) model.ArtworkID {
}
// Decode JWT token
token, err := auth.TokenAuth.Decode(tokenPart)
token, err := auth.PublicTokenAuth.Decode(tokenPart)
Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token")
c := auth.ClaimsFromToken(token)

View File

@ -68,6 +68,10 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam
options.MaxItems = 5000
}
if options.Cursor < 0 {
options.Cursor = 0
}
// Fetch one more item than requested. The last item is the next timestamp to fetch
options.MaxItems += 1
@ -81,7 +85,13 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam
}
var order string
if options.ToTimestamp != nil && options.FromTimestamp == nil {
if (options.ToTimestamp != nil) == (options.FromTimestamp != nil) {
if options.Descending {
order = "DESC"
} else {
order = "ASC"
}
} else if options.ToTimestamp != nil {
order = "DESC"
} else {
order = "ASC"
@ -92,18 +102,40 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam
Filters: filters,
Sort: "submission_time",
Order: order,
Offset: options.Cursor,
})
if err != nil {
return nil, err
}
if len(scrobbles) == 0 {
return &host.ScrobbleList{Scrobbles: []host.ScrobbleRef{}}, nil
}
cursor := 0
var nextTimestamp *int64
var targetLen int
if len(scrobbles) == options.MaxItems {
nextTimestamp = &scrobbles[options.MaxItems-1].SubmissionTime
targetLen = options.MaxItems - 1
lastTimestamp := scrobbles[len(scrobbles)-1].SubmissionTime
for i := len(scrobbles) - 2; i >= 0; i-- {
if scrobbles[i].SubmissionTime != lastTimestamp {
break
}
cursor += 1
}
// In this case, every scrobble in this query is the same timestamp
// We should continue from the previous cursor
if cursor == len(scrobbles)-1 {
cursor += options.Cursor
}
} else {
targetLen = len(scrobbles)
}
@ -119,6 +151,7 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam
response := host.ScrobbleList{
Scrobbles: scrobbleRefs,
NextTimestamp: nextTimestamp,
Cursor: cursor,
}
return &response, nil

View File

@ -11,6 +11,7 @@ import (
"strconv"
"time"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/db"
@ -30,6 +31,10 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
dataStore *tests.MockDataStore
)
p := func(val int64) *int64 {
return &val
}
BeforeAll(func() {
ctx := GinkgoT().Context()
@ -130,51 +135,43 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
})
})
var plugin *plugin
var instance *extism.Plugin
BeforeEach(func() {
manager.mu.RLock()
plugin = manager.plugins["test-scrobble-retriever"]
plugin := manager.plugins["test-scrobble-retriever"]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
var err error
ctx := GinkgoT().Context()
instance, err = plugin.instance(ctx)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
instance.Close(ctx)
})
})
Describe("not authorized", func() {
It("rejects first timestamp", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, _, err := instance.Call("call_get_first_timestamp", []byte("baduser"))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
})
It("rejects last timestamp", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, _, err := instance.Call("call_get_last_timestamp", []byte("baduser"))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
})
It("rejects scrobbles", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, _, err := instance.Call("call_get_scrobbles", []byte(`{"username":"baduser"}`))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
})
It("rejects scrobbles", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, _, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"baduser"}`))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
@ -183,10 +180,6 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
Describe("no items", func() {
It("calls get first timestamp", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_get_first_timestamp", []byte("testuser"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
@ -195,10 +188,6 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
})
It("calls get last timestamp", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_get_last_timestamp", []byte("testuser"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
@ -207,22 +196,14 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
})
It("calls scrobbles", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_get_scrobbles", []byte(`{"username":"testuser"}`))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
Expect(output).To(Equal([]byte(`{"scrobbles":[],"nextTimestamp":null}`)))
Expect(output).To(Equal([]byte(`{"scrobbles":[],"nextTimestamp":null,"cursor":0}`)))
})
It("calls get scrobble count", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"testuser"}`))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
@ -231,10 +212,6 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
})
Describe("with items", func() {
p := func(val int64) *int64 {
return &val
}
scrobbles := []host.ScrobbleRef{
{ID: 1, MediaFileID: "1", SubmissionTime: 0},
{ID: 2, MediaFileID: "2", SubmissionTime: 1},
@ -251,10 +228,6 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
})
It("calls get first timestamp", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_get_first_timestamp", []byte("adminuser"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
@ -263,10 +236,6 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
})
It("calls get last timestamp", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_get_last_timestamp", []byte("adminuser"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
@ -274,11 +243,7 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
Expect(output).To(Equal([]byte("{\"timestamp\":2}")))
})
DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, timestamp *int64) {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, timestamp *int64, cursor int) {
exit, output, err := instance.Call("call_get_scrobbles", []byte(params))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
@ -288,23 +253,22 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
Expect(scrobbleList).To(Equal(host.ScrobbleList{
Scrobbles: scrobbles,
NextTimestamp: timestamp,
Cursor: cursor,
}))
},
Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil),
Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil),
Entry("calls subset of scrobbles in ascending order, next timestamp", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], p(2)),
Entry("calls subset of scrobbles in ascending order, with offset next timestamp", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], p(2)),
Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil),
Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2}`, scrobblesReversed, nil),
Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2, "maxItems": 3}`, scrobblesReversed[:3], p(0)),
Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2, "maxItems": 1}`, scrobblesReversed[:1], p(2)),
Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil, 0),
Entry("calls scrobbles in ascending order by request", `{"username":"adminuser","descending":true}`, scrobblesReversed, nil, 0),
Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil, 0),
Entry("calls subset of scrobbles in ascending order, next timestamp", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], p(2), 0),
Entry("calls subset of scrobbles in ascending order, with offset next timestamp", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], p(2), 1),
Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil, 0),
Entry("calls subset of scrobbles in descing order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1,"descending":true}`, scrobblesReversed[:3], nil, 0),
Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2}`, scrobblesReversed, nil, 0),
Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2, "maxItems": 3}`, scrobblesReversed[:3], p(0), 0),
Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2, "maxItems": 1}`, scrobblesReversed[:1], p(2), 1),
)
DescribeTable("GetScrobblesCount", func(params string, count int) {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_get_scrobbles_count", []byte(params))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
@ -320,4 +284,70 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
Entry("accepts filter out of range", `{"username":"adminuser", "fromTimestamp": -1, "toTimestamp": 1000}`, 4),
)
})
Context("Complex edge cases - multiple scrobbles at the same timestamp", func() {
duplicates := make([]host.ScrobbleRef, 5)
duplicatesReversed := make([]host.ScrobbleRef, 5)
BeforeAll(func() {
scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"})
scrobbleRepo := dataStore.Scrobble(scrobbleCtx)
for i := range 5 {
err := scrobbleRepo.RecordScrobble("3", time.Unix(100, 0))
Expect(err).To(BeNil())
scrobble := host.ScrobbleRef{ID: 5 + int64(i), MediaFileID: "3", SubmissionTime: 100}
duplicates[i] = scrobble
duplicatesReversed[4-i] = scrobble
}
})
type TestScrobbleOptions struct {
Username string `json:"username"`
FromTimestamp *int64 `json:"fromTimestamp,omitempty"`
ToTimestamp *int64 `json:"toTimestamp,omitempty"`
Descending bool `json:"descending"`
Cursor int `json:"cursor,omitempty"`
MaxItems int `json:"maxItems"`
}
DescribeTable("Edge cases; duplicate scrobbles", func(cursor, count int, descending bool, scrobbles []host.ScrobbleRef, timestamp *int64, expectedCursor int) {
op := TestScrobbleOptions{
Username: "adminuser",
FromTimestamp: p(100),
ToTimestamp: p(100),
Descending: descending,
Cursor: cursor,
MaxItems: count,
}
payload, err := json.Marshal(op)
Expect(err).ToNot(HaveOccurred())
exit, output, err := instance.Call("call_get_scrobbles", payload)
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
var scrobbleList host.ScrobbleList
Expect(json.Unmarshal(output, &scrobbleList)).To(Succeed())
Expect(scrobbleList).To(Equal(host.ScrobbleList{
Scrobbles: scrobbles,
NextTimestamp: timestamp,
Cursor: expectedCursor,
}))
},
Entry("All tracks, in order", 0, 100, false, duplicates, nil, 0),
Entry("All tracks, in reverse order", 0, 100, true, duplicatesReversed, nil, 0),
Entry("Ascending order, from the start", 0, 1, false, duplicates[:1], p(100), 1),
Entry("Ascending order, from the start", 1, 2, false, duplicates[1:3], p(100), 3),
Entry("Ascending order, to the end", 3, 100, false, duplicates[3:], nil, 0),
Entry("Ascending order, from the start, continuing cursor", 3, 1, false, duplicates[3:4], p(100), 4),
Entry("start descending", 0, 2, true, duplicatesReversed[:2], p(100), 2),
Entry("start descending, next step", 2, 2, true, duplicatesReversed[2:4], p(100), 4),
Entry("start descending, end", 4, 1, true, duplicatesReversed[4:], nil, 0),
)
})
})

34
plugins/host_storage.go Normal file
View File

@ -0,0 +1,34 @@
package plugins
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/plugins/host"
)
const storageMount = "/storage"
type storageServiceImpl struct{}
func getHostStoragePath(pluginName string) string {
return filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName, "storage")
}
func newStorageService(pluginName string) (host.StorageService, error) {
dataDir := getHostStoragePath(pluginName)
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating plugin data directory: %w", err)
}
return &storageServiceImpl{}, nil
}
func (s *storageServiceImpl) GetStoragePath(ctx context.Context) string {
return storageMount
}
var _ host.StorageService = (*storageServiceImpl)(nil)

View File

@ -0,0 +1,270 @@
//go:build !windows
package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("utility functions", Ordered, func() {
var tmpDir string
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "storage-test-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(tmpDir)
DeferCleanup(func() {
_ = os.RemoveAll(tmpDir)
})
})
Describe("GetHostStoragePath", func() {
It("should join data folder, plugins, plugin name, and storage", func() {
actual := getHostStoragePath("plugin-name")
expected := filepath.Join(tmpDir, "plugins", "plugin-name", "storage")
Expect(actual).To(Equal(expected))
})
})
Describe("GetStoragePath", func() {
It("should return the fixed path", func() {
impl := storageServiceImpl{}
Expect(impl.GetStoragePath(context.TODO())).To(Equal("/storage"))
})
})
Describe("netStorageService", func() {
It("should create the directory on init", func() {
svc, err := newStorageService("plugin-name")
Expect(err).ToNot(HaveOccurred())
Expect(svc).ToNot(BeNil())
dataDir := filepath.Join(tmpDir, "plugins", "plugin-name", "storage")
Expect(dataDir).To(BeADirectory())
})
})
})
var _ = Describe("Storage Host Function", Ordered, func() {
const ID = "test-storage-plugin"
var (
manager *Manager
tmpDir string
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "storage-test-*")
Expect(err).ToNot(HaveOccurred())
// Setup mock router and data store
router := &fakeSubsonicRouter{}
userRepo := tests.CreateMockUserRepo()
dataStore := &tests.MockDataStore{MockedUser: userRepo}
// Create and configure manager
manager = &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
}
manager.SetSubsonicRouter(router)
mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo)
mockPluginRepo.Permitted = true
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(tmpDir)
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
pluginPaths := []string{ID, ID + "-2"}
plugins := []model.Plugin{}
for idx := range pluginPaths {
path := pluginPaths[idx] + PackageExtension
// Copy test plugin to temp dir
srcPath := filepath.Join(testdataDir, path)
destPath := filepath.Join(tmpDir, path)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Pre-enable the plugin in the mock repo so it loads on startup
// Compute SHA256 of the plugin file to match what syncPlugins will compute
pluginPath := filepath.Join(tmpDir, path)
wasmData, err := os.ReadFile(pluginPath)
Expect(err).ToNot(HaveOccurred())
hash := sha256.Sum256(wasmData)
hashHex := hex.EncodeToString(hash[:])
plugins = append(plugins, model.Plugin{
ID: pluginPaths[idx],
Path: pluginPath,
SHA256: hashHex,
Enabled: true,
AllUsers: true, // Allow all users for test plugin
})
}
mockPluginRepo.SetData(plugins)
// Start the manager
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
var instance *extism.Plugin
BeforeEach(func() {
var err error
manager.mu.RLock()
plugin := manager.plugins[ID]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
ctx := GinkgoT().Context()
instance, err = plugin.instance(ctx)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
instance.Close(ctx)
})
})
Describe("Read", func() {
BeforeAll(func() {
path := filepath.Join(getHostStoragePath(ID), "real")
err := os.WriteFile(path, []byte("1234"), 0600)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = os.Remove(path)
})
})
It("should fail to read missing file", func() {
exit, _, err := instance.Call("call_read", []byte("missing"))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
It("should read an existing file", func() {
exit, output, err := instance.Call("call_read", []byte("real"))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal([]byte("1234")))
})
It("should not escape read", func() {
path := filepath.Join(getHostStoragePath(ID), "..", "outside")
err := os.WriteFile(path, []byte("outside"), 0600)
Expect(err).ToNot(HaveOccurred())
exit, _, err := instance.Call("call_read", []byte("../outside"))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
})
Describe("Write", func() {
BeforeAll(func() {
path := filepath.Join(getHostStoragePath(ID), "real")
err := os.WriteFile(path, []byte("1234"), 0600)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = os.Remove(path)
})
})
It("should fail to write to nested file", func() {
exit, _, err := instance.Call("call_write", []byte(`{"path":"nested/file","contents":"1234"}`))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
It("should write to a file", func() {
exit, _, err := instance.Call("call_write", []byte(`{"path":"new","contents":"contents"}`))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
data, err := os.ReadFile(filepath.Join(getHostStoragePath(ID), "new"))
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]byte("contents")))
exit, output, err := instance.Call("call_read", []byte("new"))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal([]byte("contents")))
})
It("should not escape writing a file", func() {
path := filepath.Join(getHostStoragePath(ID), "..", "outside")
err := os.WriteFile(path, []byte("outside"), 0600)
Expect(err).ToNot(HaveOccurred())
exit, _, err := instance.Call("call_write", []byte(`{"path":"../new","contents":"contents"}`))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
It("should have independent storage for multiple plugins", func() {
manager.mu.RLock()
plugin2 := manager.plugins[ID+"-2"]
manager.mu.RUnlock()
Expect(plugin2).ToNot(BeNil())
secondInstance, err := plugin2.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer secondInstance.Close(GinkgoT().Context())
instances := []*extism.Plugin{instance, secondInstance}
names := []string{ID, ID + "-2"}
for idx := range instances {
exit, _, err := instances[idx].Call("call_write", fmt.Appendf(nil, `{"path":"new","contents":"%s"}`, names[idx]))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
}
for idx := range names {
data, err := os.ReadFile(filepath.Join(getHostStoragePath(names[idx]), "new"))
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]byte(names[idx])))
}
for idx := range instances {
exit, output, err := instances[idx].Call("call_read", []byte("new"))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal([]byte(names[idx])))
}
})
})
})

View File

@ -166,6 +166,17 @@ var hostServices = []hostServiceEntry{
return host.RegisterTaskHostFunctions(service), service, nil
},
},
{
name: "Storage",
hasPermission: func(p *Permissions) bool { return p != nil && p.Storage != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service, err := newStorageService(ctx.pluginName)
if err != nil {
return nil, nil, err
}
return host.RegisterStorageHostFunctions(service), nil, nil
},
},
{
name: "ScrobbleRetriever",
hasPermission: func(p *Permissions) bool { return p != nil && p.ScrobbleRetriever != nil },
@ -279,6 +290,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return fmt.Errorf("manager is stopped")
}
if !validPluginID(p.ID) {
return fmt.Errorf("invalid plugin ID %q", p.ID)
}
// Track this operation
m.loadWg.Add(1)
defer m.loadWg.Done()
@ -315,31 +330,33 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return fmt.Errorf("opening package: %w", err)
}
// Build extism manifest
pluginManifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{Data: pkg.WasmBytes, Name: "main"},
},
Config: pluginConfig,
Timeout: uint64(defaultTimeout.Milliseconds()),
}
pluginManifest := buildExtismManifest(pkg, pluginConfig)
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil {
if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 {
pluginManifest.AllowedHosts = hosts
}
}
// Configure filesystem access for library permission, applied per instance
var fsConfig wazero.FSConfig
if pkg.Manifest.HasLibraryFilesystemPermission() || pkg.Manifest.HasStoragePermission() {
mounts := []mount{}
// Configure filesystem access for library permission
if pkg.Manifest.HasLibraryFilesystemPermission() {
adminCtx := adminContext(ctx)
libraries, err := m.ds.Library(adminCtx).GetAll()
if err != nil {
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
if pkg.Manifest.HasLibraryFilesystemPermission() {
adminCtx := adminContext(ctx)
libraries, err := m.ds.Library(adminCtx).GetAll()
if err != nil {
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
}
mounts = buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
}
allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
pluginManifest.AllowedPaths = allowedPaths
if pkg.Manifest.HasStoragePermission() {
pluginStore := getHostStoragePath(p.ID)
log.Info(ctx, "Granting read-write filesystem access to plugin storage", "path", pluginStore, "id", p.ID)
mounts = append(mounts, mount{
hostPath: pluginStore,
guestPath: storageMount,
})
}
fsConfig = buildFSConfig(mounts)
}
// Build host functions based on permissions from manifest
@ -394,7 +411,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
}
// Create instance to detect capabilities
instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{})
instance, err := compiled.Instance(ctx, instanceConfig(fsConfig))
if err != nil {
compiled.Close(ctx)
return fmt.Errorf("creating instance: %w", err)
@ -421,6 +438,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
allowedUserIDs: allowedUsers,
allUsers: p.AllUsers,
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
fsConfig: fsConfig,
lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls),
}
m.mu.Unlock()
@ -467,31 +485,18 @@ func parsePluginConfig(configJSON string) (map[string]string, error) {
return pluginConfig, nil
}
// buildAllowedPaths constructs the extism AllowedPaths map for filesystem access.
// When allowWriteAccess is false (default), paths are prefixed with "ro:" for read-only.
// Only libraries that match the allowed set (or all libraries if allLibraries is true) are included.
func buildAllowedPaths(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) map[string]string {
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs))
for _, id := range allowedLibraryIDs {
allowedLibrarySet[id] = struct{}{}
// buildExtismManifest describes the plugin to extism. It must never set
// AllowedPaths: extism would replace our jailed FSConfig with plain dir mounts.
func buildExtismManifest(pkg *ndpPackage, pluginConfig map[string]string) extism.Manifest {
manifest := extism.Manifest{
Wasm: []extism.Wasm{extism.WasmData{Data: pkg.WasmBytes, Name: "main"}},
Config: pluginConfig,
Timeout: uint64(defaultTimeout.Milliseconds()),
}
allowedPaths := make(map[string]string)
for _, lib := range libraries {
_, allowed := allowedLibrarySet[lib.ID]
if allLibraries || allowed {
mountPoint := toPluginMountPoint(int32(lib.ID))
hostPath := lib.Path
if !allowWriteAccess {
hostPath = "ro:" + hostPath
}
allowedPaths[hostPath] = mountPoint
log.Trace(ctx, "Added library to allowed paths", "libraryID", lib.ID, "mountPoint", mountPoint, "writeAccess", allowWriteAccess, "hostPath", hostPath)
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil {
if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 {
manifest.AllowedHosts = hosts
}
}
if allowWriteAccess {
log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries)
} else {
log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries)
}
return allowedPaths
return manifest
}

View File

@ -6,6 +6,40 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("buildExtismManifest", func() {
var pkg *ndpPackage
BeforeEach(func() {
pkg = &ndpPackage{
WasmBytes: []byte("wasm"),
Manifest: &Manifest{Permissions: &Permissions{
Library: &LibraryPermission{Reason: new("test"), Filesystem: true},
Http: &HTTPPermission{Reason: new("test"), RequiredHosts: []string{"example.com"}},
}},
}
})
It("never sets AllowedPaths, even with filesystem permission", func() {
Expect(buildExtismManifest(pkg, nil).AllowedPaths).To(BeEmpty())
})
It("carries the hosts the plugin is allowed to reach", func() {
Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(Equal([]string{"example.com"}))
})
})
var _ = Describe("loadPluginWithConfig", func() {
// Discovery already rejects these, but a row predating that check, or one
// left behind by a failed sync, must not reach the mount setup
It("refuses a plugin whose ID is not usable as a directory name", func() {
m := &Manager{plugins: make(map[string]*plugin)}
err := m.loadPluginWithConfig(&model.Plugin{ID: "..", Path: "/does/not/matter.ndp"})
Expect(err).To(MatchError(ContainSubstring("invalid plugin ID")))
})
})
var _ = Describe("parsePluginConfig", func() {
It("returns nil for empty string", func() {
result, err := parsePluginConfig("")
@ -57,66 +91,3 @@ var _ = Describe("parsePluginConfig", func() {
Expect(result).ToNot(BeNil())
})
})
var _ = Describe("buildAllowedPaths", func() {
var libraries model.Libraries
BeforeEach(func() {
libraries = model.Libraries{
{ID: 1, Path: "/music/library1"},
{ID: 2, Path: "/music/library2"},
{ID: 3, Path: "/music/library3"},
}
})
Context("read-only (default)", func() {
It("mounts all libraries with ro: prefix when allLibraries is true", func() {
result := buildAllowedPaths(nil, libraries, nil, true, false)
Expect(result).To(HaveLen(3))
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
Expect(result).To(HaveKeyWithValue("ro:/music/library2", "/libraries/2"))
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
})
It("mounts only selected libraries with ro: prefix", func() {
result := buildAllowedPaths(nil, libraries, []int{1, 3}, false, false)
Expect(result).To(HaveLen(2))
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
Expect(result).ToNot(HaveKey("ro:/music/library2"))
})
})
Context("read-write (allowWriteAccess=true)", func() {
It("mounts all libraries without ro: prefix when allLibraries is true", func() {
result := buildAllowedPaths(nil, libraries, nil, true, true)
Expect(result).To(HaveLen(3))
Expect(result).To(HaveKeyWithValue("/music/library1", "/libraries/1"))
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
Expect(result).To(HaveKeyWithValue("/music/library3", "/libraries/3"))
})
It("mounts only selected libraries without ro: prefix", func() {
result := buildAllowedPaths(nil, libraries, []int{2}, false, true)
Expect(result).To(HaveLen(1))
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
})
})
Context("edge cases", func() {
It("returns empty map when no libraries match", func() {
result := buildAllowedPaths(nil, libraries, []int{99}, false, false)
Expect(result).To(BeEmpty())
})
It("returns empty map when libraries list is empty", func() {
result := buildAllowedPaths(nil, nil, []int{1}, false, false)
Expect(result).To(BeEmpty())
})
It("returns empty map when allLibraries is false and no IDs provided", func() {
result := buildAllowedPaths(nil, libraries, nil, false, false)
Expect(result).To(BeEmpty())
})
})
})

View File

@ -24,16 +24,24 @@ type plugin struct {
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
libraries libraryAccess
lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics)
lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics)
fsConfig wazero.FSConfig // Sandboxed library mounts, nil if no filesystem permission
}
// instanceConfig is used by every call site, so all instances get the sandboxed mounts.
func instanceConfig(fsConfig wazero.FSConfig) extism.PluginInstanceConfig {
moduleConfig := wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader)
if fsConfig != nil {
moduleConfig = moduleConfig.WithFSConfig(fsConfig)
}
return extism.PluginInstanceConfig{ModuleConfig: moduleConfig}
}
// instance creates a new plugin instance for the given context.
// The context is used for cancellation - if cancelled during a call,
// the module will be terminated and the instance becomes unusable.
func (p *plugin) instance(ctx context.Context) (*extism.Plugin, error) {
instance, err := p.compiled.Instance(ctx, extism.PluginInstanceConfig{
ModuleConfig: wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader),
})
instance, err := p.compiled.Instance(ctx, instanceConfig(p.fsConfig))
if err != nil {
return nil, err
}

View File

@ -152,7 +152,11 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error {
log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir())
continue
}
name := strings.TrimSuffix(entry.Name(), PackageExtension)
name, ok := pluginIDFromPath(entry.Name())
if !ok {
log.Warn(ctx, "Skipping plugin with unusable name", "name", entry.Name())
continue
}
filesOnDisk[name] = filepath.Join(folder, entry.Name())
}
log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk))

View File

@ -12,6 +12,46 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("syncPlugins", func() {
var m *Manager
var repo *tests.MockPluginRepo
var folder string
BeforeEach(func() {
folder = GinkgoT().TempDir()
repo = tests.CreateMockPluginRepo()
repo.SetData(model.Plugins{})
m = &Manager{ds: &tests.MockDataStore{MockedPlugin: repo}}
})
writePackage := func(name string) {
GinkgoHelper()
manifest := &Manifest{Name: "Test Plugin", Author: "Test Author", Version: "1.0.0"}
wasm := []byte{0x00, 0x61, 0x73, 0x6d} // Minimal wasm header
Expect(createTestPackage(filepath.Join(folder, name), manifest, wasm)).To(Succeed())
}
It("registers a plugin with a usable ID", func() {
writePackage("my-plugin" + PackageExtension)
Expect(m.syncPlugins(context.Background(), folder)).To(Succeed())
_, err := repo.Get("my-plugin")
Expect(err).ToNot(HaveOccurred())
})
It("skips packages whose name yields a path-like ID", func() {
writePackage("." + PackageExtension)
writePackage(".." + PackageExtension)
Expect(m.syncPlugins(context.Background(), folder)).To(Succeed())
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(all).To(BeEmpty())
})
})
var _ = Describe("removePluginFromDB", func() {
It("discards buffered scrobbles for the removed plugin", func() {
ctx := context.Background()

View File

@ -89,7 +89,11 @@ func (m *Manager) handleWatcherEvent(event notify.EventInfo) {
return
}
pluginName := strings.TrimSuffix(filepath.Base(path), PackageExtension)
pluginName, ok := pluginIDFromPath(path)
if !ok {
log.Warn(m.ctx, "Ignoring plugin file with unusable name", "path", path)
return
}
log.Trace(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path)

View File

@ -93,6 +93,9 @@
"matcher": {
"$ref": "#/$defs/MatcherPermission"
},
"storage": {
"$ref": "#/$defs/StoragePermission"
},
"scrobbleRetriever": {
"$ref": "#/$defs/ScrobbleRetrieverPermission"
}
@ -248,6 +251,17 @@
}
}
},
"StoragePermission": {
"type": "object",
"description": "Storage permissions for enabling persistent read-write storage exclusively for the plugin",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why storage access is needed"
}
}
},
"ScrobbleRetrieverPermission": {
"type": "object",
"description": "Scrobble retriever permissions for retrieving scrobbles from users",

View File

@ -127,3 +127,7 @@ func (m *Manifest) HasLibraryFilesystemPermission() bool {
m.Permissions.Library != nil &&
m.Permissions.Library.Filesystem
}
func (m *Manifest) HasStoragePermission() bool {
return m.Permissions != nil && m.Permissions.Storage != nil
}

View File

@ -181,6 +181,9 @@ type Permissions struct {
// ScrobbleRetriever corresponds to the JSON schema field "scrobbleRetriever".
ScrobbleRetriever *ScrobbleRetrieverPermission `json:"scrobbleRetriever,omitempty" yaml:"scrobbleRetriever,omitempty" mapstructure:"scrobbleRetriever,omitempty"`
// Storage corresponds to the JSON schema field "storage".
Storage *StoragePermission `json:"storage,omitempty" yaml:"storage,omitempty" mapstructure:"storage,omitempty"`
// Subsonicapi corresponds to the JSON schema field "subsonicapi".
Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"`
@ -206,6 +209,13 @@ type ScrobbleRetrieverPermission struct {
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Storage permissions for enabling persistent read-write storage exclusively for
// the plugin
type StoragePermission struct {
// Explanation for why storage access is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// SubsonicAPI service permissions. Requires 'users' permission to be declared.
type SubsonicAPIPermission struct {
// Explanation for why SubsonicAPI access is needed

View File

@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"io"
"path/filepath"
"strings"
)
const (
@ -18,6 +20,26 @@ const (
wasmFileName = "plugin.wasm"
)
// validPluginID reports whether id is usable. It names a directory under
// DataFolder/plugins, so a path-like one would point at another plugin's data.
func validPluginID(id string) bool {
if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || !filepath.IsLocal(id) {
return false
}
// Windows drops trailing dots and spaces, so "foo." and "foo" would end up
// sharing a directory
return strings.TrimRight(id, ". ") == id
}
// pluginIDFromPath derives the plugin ID from a package path.
func pluginIDFromPath(path string) (string, bool) {
id := strings.TrimSuffix(filepath.Base(path), PackageExtension)
if !validPluginID(id) {
return "", false
}
return id, true
}
// ndpPackage represents a loaded .ndp plugin package.
// It contains the manifest and wasm bytes read from the archive.
type ndpPackage struct {

View File

@ -11,6 +11,36 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("pluginIDFromPath", func() {
DescribeTable("derives the ID from the package filename",
func(path, expected string) {
id, ok := pluginIDFromPath(path)
Expect(ok).To(BeTrue())
Expect(id).To(Equal(expected))
},
Entry("plain name", "/plugins/discord-rich-presence.ndp", "discord-rich-presence"),
Entry("name with spaces", "/plugins/My Plugin.ndp", "My Plugin"),
Entry("leading dot", "/plugins/.hidden.ndp", ".hidden"),
Entry("dots inside", "/plugins/v1.2.3.ndp", "v1.2.3"),
)
// The ID becomes a directory name under DataFolder/plugins, so a path-like
// one would let a plugin reach another plugin's data
DescribeTable("rejects IDs that are unsafe as a directory name",
func(path string) {
_, ok := pluginIDFromPath(path)
Expect(ok).To(BeFalse())
},
Entry("empty", "/plugins/.ndp"),
Entry("current directory", "/plugins/..ndp"),
Entry("parent directory", "/plugins/...ndp"),
// Windows drops trailing dots and spaces, so these would share a
// directory with "foo"
Entry("trailing dot", "/plugins/foo..ndp"),
Entry("trailing space", "/plugins/foo .ndp"),
)
})
var _ = Describe("ndpPackage", func() {
var tmpDir string

View File

@ -44,6 +44,7 @@ The following host services are available:
- Matcher: resolves externally-obtained songs to local library tracks,
- Scheduler: provides task scheduling capabilities for plugins.
- ScrobbleRetriever: allows a plugin to retrieve scrobbles for one or more authorized users.
- Storage: provides access to a plugin-specific directory with read/write permissions
- SubsonicAPI: provides access to Navidrome's Subsonic API from plugins.
- Task: provides persistent task queues for plugins.
- Users: provides access to user information for plugins.

View File

@ -27,6 +27,7 @@ type ScrobbleCountOptions struct {
type ScrobbleList struct {
Scrobbles []ScrobbleRef `json:"scrobbles"`
NextTimestamp *int64 `json:"nextTimestamp"`
Cursor int `json:"cursor"`
}
// ScrobbleOptions represents the ScrobbleOptions data structure.
@ -34,6 +35,8 @@ type ScrobbleList struct {
type ScrobbleOptions struct {
FromTimestamp *int64 `json:"fromTimestamp"`
ToTimestamp *int64 `json:"toTimestamp"`
Descending bool `json:"descending"`
Cursor int `json:"cursor"`
MaxItems int `json:"maxItems"`
}

View File

@ -25,6 +25,7 @@ type ScrobbleCountOptions struct {
type ScrobbleList struct {
Scrobbles []ScrobbleRef `json:"scrobbles"`
NextTimestamp *int64 `json:"nextTimestamp"`
Cursor int `json:"cursor"`
}
// ScrobbleOptions represents the ScrobbleOptions data structure.
@ -32,6 +33,8 @@ type ScrobbleList struct {
type ScrobbleOptions struct {
FromTimestamp *int64 `json:"fromTimestamp"`
ToTimestamp *int64 `json:"toTimestamp"`
Descending bool `json:"descending"`
Cursor int `json:"cursor"`
MaxItems int `json:"maxItems"`
}

View File

@ -0,0 +1,46 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the Storage host service.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package host
import (
"encoding/json"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// storage_getstoragepath is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user storage_getstoragepath
func storage_getstoragepath(uint64) uint64
type storageGetStoragePathResponse struct {
Result string `json:"result,omitempty"`
}
// StorageGetStoragePath calls the storage_getstoragepath host function.
// GetStoragePath retrieves the persistent storage path, if allowed
func StorageGetStoragePath() string {
// No parameters - allocate empty JSON object
reqMem := pdk.AllocateBytes([]byte("{}"))
defer reqMem.Free()
// Call the host function
responsePtr := storage_getstoragepath(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response storageGetStoragePathResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return ""
}
return response.Result
}

View File

@ -0,0 +1,34 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains mock implementations for non-WASM builds.
// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms.
// Plugin authors can use the exported mock instances to set expectations in tests.
//
//go:build !wasip1
package host
import (
"github.com/stretchr/testify/mock"
)
// mockStorageService is the mock implementation for testing.
type mockStorageService struct {
mock.Mock
}
// StorageMock is the auto-instantiated mock instance for testing.
// Use this to set expectations: host.StorageMock.On("MethodName", args...).Return(values...)
var StorageMock = &mockStorageService{}
// GetStoragePath is the mock method for StorageGetStoragePath.
func (m *mockStorageService) GetStoragePath() string {
args := m.Called()
return args.String(0)
}
// StorageGetStoragePath delegates to the mock instance.
// GetStoragePath retrieves the persistent storage path, if allowed
func StorageGetStoragePath() string {
return StorageMock.GetStoragePath()
}

View File

@ -41,6 +41,7 @@
//! - [`matcher`] - resolves externally-obtained songs to local library tracks,
//! - [`scheduler`] - provides task scheduling capabilities for plugins.
//! - [`scrobbleretriever`] - allows a plugin to retrieve scrobbles for one or more authorized users.
//! - [`storage`] - provides access to a plugin-specific directory with read/write permissions
//! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins.
//! - [`task`] - provides persistent task queues for plugins.
//! - [`users`] - provides access to user information for plugins.
@ -109,6 +110,13 @@ pub mod scrobbleretriever {
pub use super::nd_host_scrobbleretriever::*;
}
#[doc(hidden)]
mod nd_host_storage;
/// provides access to a plugin-specific directory with read/write permissions
pub mod storage {
pub use super::nd_host_storage::*;
}
#[doc(hidden)]
mod nd_host_subsonicapi;
/// provides access to Navidrome's Subsonic API from plugins.

View File

@ -24,6 +24,8 @@ pub struct ScrobbleList {
pub scrobbles: Vec<ScrobbleRef>,
#[serde(default)]
pub next_timestamp: Option<i64>,
#[serde(default)]
pub cursor: i32,
}
/// ScrobbleOptions carries optional parameters for retrieving user scrobbles
@ -34,6 +36,9 @@ pub struct ScrobbleOptions {
pub from_timestamp: Option<i64>,
#[serde(default)]
pub to_timestamp: Option<i64>,
pub descending: bool,
#[serde(default)]
pub cursor: i32,
pub max_items: i32,
}

View File

@ -0,0 +1,34 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the Storage host service.
// It is intended for use in Navidrome plugins built with extism-pdk.
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StorageGetStoragePathResponse {
#[serde(default)]
result: String,
}
#[host_fn]
extern "ExtismHost" {
fn storage_getstoragepath(input: Json<serde_json::Value>) -> Json<StorageGetStoragePathResponse>;
}
/// GetStoragePath retrieves the persistent storage path, if allowed
///
/// # Returns
/// The result value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get_storage_path() -> Result<String, Error> {
let response = unsafe {
storage_getstoragepath(Json(serde_json::json!({})))?
};
Ok(response.0.result)
}

View File

@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@ -81,46 +82,43 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu
return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...)
}
// installTestPlugins copies the given .ndp packages into dir and returns their
// enabled DB rows, so callers can grant whatever access the test needs.
func installTestPlugins(dir string, plugins ...string) model.Plugins {
var rows model.Plugins
for _, plugin := range plugins {
data, err := os.ReadFile(filepath.Join(testdataDir, plugin))
Expect(err).ToNot(HaveOccurred())
destPath := filepath.Join(dir, plugin)
Expect(os.WriteFile(destPath, data, 0600)).To(Succeed())
hash := sha256.Sum256(data)
rows = append(rows, model.Plugin{
ID: strings.TrimSuffix(plugin, PackageExtension),
Path: destPath,
SHA256: hex.EncodeToString(hash[:]),
Enabled: true,
})
}
return rows
}
// createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config,
// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager.
// Returns the manager and temp directory path.
// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins,
// and starts the manager. Returns the manager and temp directory path.
func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy test plugins to temp dir and build plugin list with SHA256
var enabledPlugins model.Plugins
for _, plugin := range plugins {
srcPath := filepath.Join(testdataDir, plugin)
destPath := filepath.Join(tmpDir, plugin)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Compute SHA256 for the plugin
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
pluginName := plugin[:len(plugin)-len(PackageExtension)] // Remove .ndp extension
// Build config JSON if provided
configJSON := ""
if pluginConfig != nil && pluginConfig[pluginName] != nil {
// Encode config to JSON
configBytes, err := json.Marshal(pluginConfig[pluginName])
enabledPlugins := installTestPlugins(tmpDir, plugins...)
for i, p := range enabledPlugins {
enabledPlugins[i].AllUsers = true // Allow all users by default in tests
if pluginConfig[p.ID] != nil {
configBytes, err := json.Marshal(pluginConfig[p.ID])
Expect(err).ToNot(HaveOccurred())
configJSON = string(configBytes)
enabledPlugins[i].Config = string(configBytes)
}
enabledPlugins = append(enabledPlugins, model.Plugin{
ID: pluginName,
Path: destPath,
SHA256: hashHex,
Enabled: true,
Config: configJSON,
AllUsers: true, // Allow all users by default in tests
})
}
// Setup config

157
plugins/sandbox_fs.go Normal file
View File

@ -0,0 +1,157 @@
package plugins
import (
"context"
"io/fs"
"path/filepath"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/tetratelabs/wazero"
experimentalsys "github.com/tetratelabs/wazero/experimental/sys"
"github.com/tetratelabs/wazero/experimental/sysfs"
"github.com/tetratelabs/wazero/sys"
)
// jailedFS confines a mount to its root: wazero resolves guest paths by bare
// concatenation. Named field, not embedding, so a new sys.FS method can't slip through.
type jailedFS struct {
fs experimentalsys.FS
}
// escapes reports whether a guest path can resolve outside the mount root.
// WASI only validates "/"-separated paths, missing `..\`, `C:x` and device names.
func escapes(path string) bool {
switch path {
case "", ".", "/": // how wazero opens the mount root itself
return false
}
return !filepath.IsLocal(path)
}
func (jailedFS) Symlink(string, string) experimentalsys.Errno {
return experimentalsys.EPERM
}
func (j jailedFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) {
if escapes(path) {
return nil, experimentalsys.EPERM
}
return j.fs.OpenFile(path, flag, perm)
}
func (j jailedFS) Lstat(path string) (sys.Stat_t, experimentalsys.Errno) {
if escapes(path) {
return sys.Stat_t{}, experimentalsys.EPERM
}
return j.fs.Lstat(path)
}
func (j jailedFS) Stat(path string) (sys.Stat_t, experimentalsys.Errno) {
if escapes(path) {
return sys.Stat_t{}, experimentalsys.EPERM
}
return j.fs.Stat(path)
}
func (j jailedFS) Mkdir(path string, perm fs.FileMode) experimentalsys.Errno {
if escapes(path) {
return experimentalsys.EPERM
}
return j.fs.Mkdir(path, perm)
}
func (j jailedFS) Chmod(path string, perm fs.FileMode) experimentalsys.Errno {
if escapes(path) {
return experimentalsys.EPERM
}
return j.fs.Chmod(path, perm)
}
func (j jailedFS) Rename(from, to string) experimentalsys.Errno {
if escapes(from) || escapes(to) {
return experimentalsys.EPERM
}
return j.fs.Rename(from, to)
}
func (j jailedFS) Rmdir(path string) experimentalsys.Errno {
if escapes(path) {
return experimentalsys.EPERM
}
return j.fs.Rmdir(path)
}
func (j jailedFS) Unlink(path string) experimentalsys.Errno {
if escapes(path) {
return experimentalsys.EPERM
}
return j.fs.Unlink(path)
}
func (j jailedFS) Link(oldPath, newPath string) experimentalsys.Errno {
if escapes(oldPath) || escapes(newPath) {
return experimentalsys.EPERM
}
return j.fs.Link(oldPath, newPath)
}
func (j jailedFS) Readlink(path string) (string, experimentalsys.Errno) {
if escapes(path) {
return "", experimentalsys.EPERM
}
return j.fs.Readlink(path)
}
func (j jailedFS) Utimens(path string, atim, mtim int64) experimentalsys.Errno {
if escapes(path) {
return experimentalsys.EPERM
}
return j.fs.Utimens(path, atim, mtim)
}
// mount is a host directory exposed to a plugin at guestPath.
type mount struct {
hostPath string
guestPath string
readOnly bool
}
// buildMounts lists the libraries the plugin may reach through the filesystem.
func buildMounts(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) []mount {
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs))
for _, id := range allowedLibraryIDs {
allowedLibrarySet[id] = struct{}{}
}
var mounts []mount
for _, lib := range libraries {
_, allowed := allowedLibrarySet[lib.ID]
if allLibraries || allowed {
m := mount{hostPath: lib.Path, guestPath: toPluginMountPoint(int32(lib.ID)), readOnly: !allowWriteAccess}
mounts = append(mounts, m)
log.Trace(ctx, "Added library to plugin mounts", "libraryID", lib.ID, "mountPoint", m.guestPath, "readOnly", m.readOnly, "hostPath", m.hostPath)
}
}
if allowWriteAccess {
log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries)
} else {
log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries)
}
return mounts
}
// buildFSConfig mounts each host directory jailed to its root.
func buildFSConfig(mounts []mount) wazero.FSConfig {
if len(mounts) == 0 {
return nil
}
cfg := wazero.NewFSConfig()
for _, m := range mounts {
var mounted experimentalsys.FS = jailedFS{fs: sysfs.DirFS(m.hostPath)}
if m.readOnly {
mounted = &sysfs.ReadFS{FS: mounted}
}
cfg = cfg.(sysfs.FSConfig).WithSysFSMount(mounted, m.guestPath)
}
return cfg
}

View File

@ -0,0 +1,127 @@
package plugins
import (
"os"
"path/filepath"
"runtime"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
experimentalsys "github.com/tetratelabs/wazero/experimental/sys"
"github.com/tetratelabs/wazero/experimental/sysfs"
)
var _ = Describe("buildMounts", func() {
var libraries model.Libraries
BeforeEach(func() {
libraries = model.Libraries{
{ID: 1, Path: "/music/library1"},
{ID: 2, Path: "/music/library2"},
{ID: 3, Path: "/music/library3"},
}
})
It("mounts all libraries read-only by default", func() {
Expect(buildMounts(nil, libraries, nil, true, false)).To(Equal([]mount{
{hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true},
{hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: true},
{hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true},
}))
})
It("mounts only the selected libraries", func() {
Expect(buildMounts(nil, libraries, []int{1, 3}, false, false)).To(Equal([]mount{
{hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true},
{hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true},
}))
})
It("mounts writable when write access is granted", func() {
Expect(buildMounts(nil, libraries, []int{2}, false, true)).To(Equal([]mount{
{hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: false},
}))
})
DescribeTable("mounts nothing",
func(libs model.Libraries, allowedIDs []int, allLibraries bool) {
Expect(buildMounts(nil, libs, allowedIDs, allLibraries, false)).To(BeEmpty())
},
Entry("when no library matches", libraries, []int{99}, false),
Entry("when there are no libraries", model.Libraries(nil), []int{1}, false),
Entry("when no library is selected", libraries, nil, false),
)
})
var _ = Describe("jailedFS", func() {
var (
fs jailedFS
root string
outsideDir string
)
BeforeEach(func() {
tmpDir := GinkgoT().TempDir()
root = filepath.Join(tmpDir, "root")
outsideDir = filepath.Join(tmpDir, "outside")
Expect(os.MkdirAll(root, 0755)).To(Succeed())
Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0600)).To(Succeed())
fs = jailedFS{sysfs.DirFS(root)}
})
It("denies creating symlinks", func() {
Expect(fs.Symlink("../outside", "link")).To(Equal(experimentalsys.EPERM))
})
It("denies reading through a path that escapes the root", func() {
_, errno := fs.OpenFile("../outside/secret.txt", experimentalsys.O_RDONLY, 0)
Expect(errno).To(Equal(experimentalsys.EPERM))
})
It("denies traversal with the host separator", func() {
_, errno := fs.OpenFile(`..\outside\secret.txt`, experimentalsys.O_RDONLY, 0)
if runtime.GOOS == "windows" {
Expect(errno).To(Equal(experimentalsys.EPERM))
} else {
// A backslash is a legal filename character on POSIX, not a separator
Expect(errno).To(Equal(experimentalsys.ENOENT))
}
})
It("denies absolute paths", func() {
_, errno := fs.OpenFile("/etc/passwd", experimentalsys.O_RDONLY, 0)
Expect(errno).To(Equal(experimentalsys.EPERM))
})
It("denies creating a directory outside the root", func() {
Expect(fs.Mkdir("../outside/new-dir", 0755)).To(Equal(experimentalsys.EPERM))
Expect(filepath.Join(outsideDir, "new-dir")).ToNot(BeADirectory())
})
It("denies escaping through either side of a rename", func() {
Expect(fs.Rename("../outside/secret.txt", "stolen.txt")).To(Equal(experimentalsys.EPERM))
Expect(fs.Rename("inside.txt", "../outside/leaked.txt")).To(Equal(experimentalsys.EPERM))
})
// Music libraries symlink in folders from elsewhere; following them is intended
It("follows symlinks that already exist in the mount", func() {
if err := os.Symlink(outsideDir, filepath.Join(root, "linked")); err != nil {
Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges
}
_, errno := fs.OpenFile("linked/secret.txt", experimentalsys.O_RDONLY, 0)
Expect(errno).To(BeZero())
})
It("allows access within the root", func() {
Expect(fs.Mkdir("sub", 0755)).To(BeZero())
f, errno := fs.OpenFile("sub/file.txt", experimentalsys.O_CREAT|experimentalsys.O_WRONLY, 0600)
Expect(errno).To(BeZero())
Expect(f.Close()).To(BeZero())
Expect(filepath.Join(root, "sub", "file.txt")).To(BeAnExistingFile())
})
})

182
plugins/sandbox_fs_test.go Normal file
View File

@ -0,0 +1,182 @@
//go:build !windows
package plugins
import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type sandboxInput struct {
Operation string `json:"operation"`
MountPoint string `json:"mount_point,omitempty"`
FilePath string `json:"file_path,omitempty"`
Content string `json:"content,omitempty"`
Target string `json:"target,omitempty"`
}
type sandboxOutput struct {
FileContent string `json:"file_content,omitempty"`
Error *string `json:"error,omitempty"`
}
// startSandboxManager loads test-library against libraryDir with the given grant.
func startSandboxManager(tmpDir, libraryDir string, grant func(*model.Plugin)) *Manager {
GinkgoHelper()
installed := installTestPlugins(tmpDir, "test-library"+PackageExtension)
grant(&installed[0])
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(installed)
mockLibraryRepo := &tests.MockLibraryRepo{}
mockLibraryRepo.SetData(model.Libraries{{ID: 1, Name: "Test Library", Path: libraryDir}})
manager := &Manager{
plugins: make(map[string]*plugin),
ds: &tests.MockDataStore{MockedPlugin: mockPluginRepo, MockedLibrary: mockLibraryRepo},
subsonicRouter: http.NotFoundHandler(),
}
Expect(manager.Start(GinkgoT().Context())).To(Succeed())
DeferCleanup(func() { _ = manager.Stop() })
return manager
}
// callSandbox runs one filesystem operation inside the plugin sandbox.
func callSandbox(manager *Manager, input sandboxInput) sandboxOutput {
GinkgoHelper()
manager.mu.RLock()
p := manager.plugins["test-library"]
manager.mu.RUnlock()
Expect(p).ToNot(BeNil())
instance, err := p.instance(context.Background())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(context.Background())
inputBytes, err := json.Marshal(input)
Expect(err).ToNot(HaveOccurred())
_, outputBytes, err := instance.Call("nd_test_library", inputBytes)
Expect(err).ToNot(HaveOccurred())
var output sandboxOutput
Expect(json.Unmarshal(outputBytes, &output)).To(Succeed())
return output
}
var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func() {
var (
manager *Manager
libraryDir string
outsideDir string
secretFile string
mountPoint string
)
call := func(input sandboxInput) sandboxOutput {
GinkgoHelper()
input.MountPoint = mountPoint
return callSandbox(manager, input)
}
BeforeAll(func() {
tmpDir := GinkgoT().TempDir()
libraryDir = filepath.Join(tmpDir, "music-library")
Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed())
// Sibling of the mount root: anything reached here escaped the sandbox
outsideDir = filepath.Join(tmpDir, "outside")
Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed())
secretFile = filepath.Join(outsideDir, "secret.txt")
Expect(os.WriteFile(secretFile, []byte("secret"), 0600)).To(Succeed())
mountPoint = toPluginMountPoint(1)
manager = startSandboxManager(tmpDir, libraryDir, func(p *model.Plugin) {
p.AllLibraries = true
p.AllowWriteAccess = true
})
})
It("allows writing inside the mount", func() {
out := call(sandboxInput{Operation: "write_file", FilePath: "inside.txt", Content: "hello"})
Expect(out.Error).To(BeNil())
Expect(os.ReadFile(filepath.Join(libraryDir, "inside.txt"))).To(BeEquivalentTo("hello"))
})
It("cannot escape the mount by creating a symlink", func() {
linked := call(sandboxInput{Operation: "symlink", FilePath: "escape", Target: "../outside"})
Expect(linked.Error).ToNot(BeNil())
_, err := os.Lstat(filepath.Join(libraryDir, "escape"))
Expect(err).To(MatchError(os.ErrNotExist), "the plugin created a symlink out of the mount")
wrote := call(sandboxInput{Operation: "write_file", FilePath: "escape/via-created-symlink.txt", Content: "escaped"})
Expect(wrote.Error).ToNot(BeNil())
Expect(filepath.Join(outsideDir, "via-created-symlink.txt")).ToNot(BeAnExistingFile())
})
// Accepted residual, pinned so a future tightening can't happen silently
It("still follows a symlink planted in the mount by something else", func() {
Expect(os.Symlink(outsideDir, filepath.Join(libraryDir, "planted"))).To(Succeed())
out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"})
Expect(out.Error).To(BeNil())
Expect(filepath.Join(outsideDir, "via-symlink.txt")).To(BeAnExistingFile())
})
It("cannot read outside the mount with ..", func() {
out := call(sandboxInput{Operation: "read_file", FilePath: "../outside/secret.txt"})
Expect(out.Error).ToNot(BeNil())
Expect(out.FileContent).ToNot(Equal("secret"))
})
It("cannot write outside the mount with ..", func() {
out := call(sandboxInput{Operation: "write_file", FilePath: "../outside/via-dotdot.txt", Content: "escaped"})
Expect(out.Error).ToNot(BeNil())
Expect(filepath.Join(outsideDir, "via-dotdot.txt")).ToNot(BeAnExistingFile())
})
})
var _ = Describe("Plugin filesystem sandbox without library access", Ordered, func() {
var manager *Manager
var libraryDir string
BeforeAll(func() {
tmpDir := GinkgoT().TempDir()
libraryDir = filepath.Join(tmpDir, "music-library")
Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libraryDir, "track.txt"), []byte("audio"), 0600)).To(Succeed())
manager = startSandboxManager(tmpDir, libraryDir, func(*model.Plugin) {})
})
It("mounts nothing when no library is granted", func() {
manager.mu.RLock()
p := manager.plugins["test-library"]
manager.mu.RUnlock()
Expect(p.fsConfig).To(BeNil())
out := callSandbox(manager, sandboxInput{Operation: "read_file", MountPoint: toPluginMountPoint(1), FilePath: "track.txt"})
Expect(out.Error).ToNot(BeNil())
})
})

View File

@ -14,10 +14,12 @@ import (
// TestLibraryInput is the input for nd_test_library callback.
type TestLibraryInput struct {
Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir"
Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir", "write_file", "symlink"
LibraryID int32 `json:"library_id,omitempty"`
MountPoint string `json:"mount_point,omitempty"` // For filesystem operations
FilePath string `json:"file_path,omitempty"` // For read_file operation (relative to mount point)
Content string `json:"content,omitempty"` // For write_file operation
Target string `json:"target,omitempty"` // For symlink operation
}
// TestLibraryOutput is the output from nd_test_library callback.
@ -88,6 +90,28 @@ func ndTestLibrary() int32 {
pdk.OutputJSON(TestLibraryOutput{DirEntries: names})
return 0
case "write_file":
// Write a file to the mounted library directory
fullPath := filepath.Join(input.MountPoint, input.FilePath)
if err := os.WriteFile(fullPath, []byte(input.Content), 0600); err != nil {
errStr := err.Error()
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestLibraryOutput{})
return 0
case "symlink":
// Create a symlink inside the mounted library directory
fullPath := filepath.Join(input.MountPoint, input.FilePath)
if err := os.Symlink(input.Target, fullPath); err != nil {
errStr := err.Error()
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestLibraryOutput{})
return 0
default:
errStr := "unknown operation: " + input.Operation
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})

View File

@ -47,6 +47,8 @@ type TestScrobbleOptions struct {
Username string `json:"username"`
FromTimestamp *int64 `json:"fromTimestamp,omitempty"`
ToTimestamp *int64 `json:"toTimestamp,omitempty"`
Descending bool `json:"descending"`
Cursor int `json:"cursor,omitempty"`
MaxItems int `json:"maxItems"`
}
@ -64,6 +66,8 @@ func callGetScrobbles() int32 {
FromTimestamp: options.FromTimestamp,
ToTimestamp: options.ToTimestamp,
MaxItems: options.MaxItems,
Descending: options.Descending,
Cursor: options.Cursor,
})
if err != nil {

1
plugins/testdata/test-storage-plugin-2 vendored Symbolic link
View File

@ -0,0 +1 @@
test-storage-plugin

View File

@ -0,0 +1,16 @@
module test-storage-plugin
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,50 @@
// Test plugin for Storage host function integration tests.
// Build with: tinygo build -o ../test-storage-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go
package main
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
//go:wasmexport call_read
func callRead() int32 {
path := filepath.Join(host.StorageGetStoragePath(), pdk.InputString())
content, err := os.ReadFile(path)
if err != nil {
pdk.SetErrorString("failed to read file: " + err.Error())
return 1
}
pdk.Output(content)
return 0
}
type WriteInput struct {
Path string `json:"path"`
Contents string `json:"contents"`
}
//go:wasmexport call_write
func callWrite() int32 {
var config WriteInput
err := pdk.InputJSON(&config)
if err != nil {
pdk.SetErrorString("failed to parse json: " + err.Error())
return 1
}
path := filepath.Join(host.StorageGetStoragePath(), config.Path)
err = os.WriteFile(path, []byte(config.Contents), 0600)
if err != nil {
pdk.SetErrorString("failed to write file: " + err.Error())
return 1
}
return 0
}
func main() {}

View File

@ -0,0 +1,11 @@
{
"name": "Test Storage Plugin",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "Test plugin for storage host function",
"permissions": {
"storage": {
"reason": "persistent storage for testing"
}
}
}

View File

@ -32,7 +32,7 @@
"rating": "评分",
"quality": "品质",
"bpm": "BPM",
"playDate": "最后一次播放",
"playDate": "上次播放",
"createdAt": "加入日期",
"grouping": "分组",
"mood": "情绪",
@ -184,7 +184,7 @@
"maxBitRate": "最大比特率",
"client": "客户端",
"userName": "用户名",
"lastSeen": "上次浏览",
"lastSeen": "上次使用",
"reportRealPath": "报告真实路径",
"scrobbleEnabled": "发送个性化记录到外部服务"
}
@ -210,7 +210,8 @@
"songCount": "歌曲数",
"comment": "注释",
"sync": "自动导入",
"path": "导入路径"
"path": "导入路径",
"starred": "收藏"
},
"actions": {
"selectPlaylist": "选择歌单",
@ -530,7 +531,7 @@
"no_results": "未找到结果",
"no_more_results": "页码 %{page} 超出范围,尝试返回上一页",
"page_out_of_boundaries": "页码 %{page} 超出范围",
"page_out_from_end": "已经最后一页",
"page_out_from_end": "已经是最后一页",
"page_out_from_begin": "已经是第一页",
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
"page_rows_per_page": "每页项目数:",
@ -636,6 +637,7 @@
"albumList": "专辑",
"playlists": "歌单",
"sharedPlaylists": "共享的歌单",
"onlyFavourites": "仅显示收藏项",
"about": "关于"
},
"player": {
@ -668,10 +670,10 @@
"homepage": "主页",
"source": "源代码",
"featureRequests": "功能需求",
"lastInsightsCollection": "最近的分析收集",
"lastInsightsCollection": "上次的匿名数据收集",
"insights": {
"disabled": "禁用",
"waiting": "等待"
"disabled": "已禁用",
"waiting": "等待中"
}
},
"tabs": {

View File

@ -90,9 +90,9 @@ skipped, so it doesn't create a nameless player.
Navidrome item ids are **hex-encoded at the API boundary** (`dto.EncodeID`/`DecodeID`): every id
is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients
parse ids as radix-16 — Finamp's queue `packIds`, for instance, does `int.parse(chunk, radix:16)`,
which chokes on Navidrome's base-62 nanoids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a raw MD5 id
from an old migrated library is itself valid hex, correctness depends on every emit path encoding
and every receive path decoding — see `dto/ids.go`.
which chokes on Navidrome's base62 ids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a base62 id can
itself be valid hex, correctness depends on every emit path encoding and every receive path
decoding — see `dto/ids.go`.
## Multi-library behavior
@ -184,13 +184,13 @@ their Navidrome `ArtworkID`.
Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that
when persisting its play queue across restarts: `packIds()` bit-packs every id into exactly 16
bytes. Navidrome ids are longer (nanoid ids can exceed 128 bits, so they cannot be mapped into
GUIDs), which means Finamp silently stores only the first 16 characters of each id and asks for
those **truncated ids** back when restoring the queue — item lookups, then streaming, images,
favorites and playback reports for the restored tracks.
bytes. Navidrome ids are 22-character base62 strings, not 32-hex GUIDs, which means Finamp silently
stores only the first 16 characters of each id and asks for those **truncated ids** back when
restoring the queue — item lookups, then streaming, images, favorites and playback reports for the
restored tracks.
This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome
id family uses — is resolved to the full id by unique-prefix lookup (an indexed range scan;
id uses — is resolved to the full id by unique-prefix lookup (an indexed range scan;
ambiguity is detected and fails safe). The `/Items?ids=` batch response echoes the id **as
requested**, because Finamp matches restored items back to its stored ids, and the other item
endpoints accept truncated ids transparently.

View File

@ -3,7 +3,7 @@ package dto
import "encoding/hex"
// EncodeID renders a Navidrome id as lowercase hex; Jellyfin clients parse ids as radix-16 (e.g.
// Finamp's queue packing) and crash on Navidrome's base62 nanoids if emitted as-is.
// Finamp's queue packing) and crash on Navidrome's base62 ids if emitted as-is.
func EncodeID(id string) string {
if id == "" {
return ""

View File

@ -6,7 +6,7 @@ import (
)
var _ = Describe("id codec", func() {
It("round-trips a base62 nanoid through Encode/Decode", func() {
It("round-trips a base62 id", func() {
id := "5QFKvMsJrd57QE2Le2dKKo"
Expect(DecodeID(EncodeID(id))).To(Equal(id))
})

View File

@ -11,8 +11,8 @@ import (
)
// truncatedIDLen is what Finamp's saved-queue persistence cuts item ids to (16 bytes, assuming
// Jellyfin GUIDs). No Navidrome id family is 16 chars (nanoid=22, legacy MD5=32, playlist
// UUID=36), so the length alone identifies a truncated id. See README.
// Jellyfin GUIDs). All Navidrome ids are 22 chars (share ids 10), so length alone flags a
// truncated id. See README.
//
// Handlers taking an item id resolve it via resolveItemID/resolveItemIDs; playlist-write handlers
// and ParentId scoping don't (a restored queue never edits playlists or browses by container id).

View File

@ -1,18 +1,41 @@
package public
import (
"cmp"
"fmt"
"net/http"
"strings"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/str"
)
func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := req.Params(r).String(":id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = pub.archiver.ZipShare(r.Context(), id, w)
checkShareError(r.Context(), w, err, id)
// Load the share before streaming: once ZipShare writes its first byte the
// status is locked at 200, so errors could no longer be reported.
s, err := pub.share.Load(ctx, id)
if err != nil {
checkShareError(ctx, w, err, id)
return
}
if !s.Downloadable {
checkShareError(ctx, w, model.ErrNotAuthorized, id)
return
}
name := str.SanitizeFilename(cmp.Or(s.Description, s.ID))
name = strings.ReplaceAll(name, ",", "_")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name+".zip"))
w.Header().Set("Content-Type", "application/zip")
err = pub.archiver.ZipShare(ctx, s, w)
checkShareError(ctx, w, err, id)
}

View File

@ -0,0 +1,134 @@
package public
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type mockArchiver struct {
called bool
err error
}
func (m *mockArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error {
return nil
}
func (m *mockArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error {
return nil
}
func (m *mockArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error {
return nil
}
func (m *mockArchiver) ZipShare(_ context.Context, _ *model.Share, w io.Writer) error {
m.called = true
if m.err != nil {
return m.err
}
_, _ = w.Write([]byte("zip-contents"))
return nil
}
var _ = Describe("handleDownloads", func() {
var ds *tests.MockDataStore
var shareRepo *tests.MockShareRepo
var archiver *mockArchiver
var pub *Router
BeforeEach(func() {
ds = &tests.MockDataStore{}
shareRepo = &tests.MockShareRepo{}
ds.MockedShare = shareRepo
archiver = &mockArchiver{}
pub = &Router{ds: ds, archiver: archiver, share: core.NewShare(ds)}
})
shareIs := func(s *model.Share) {
shareRepo.ID = s.ID
shareRepo.Entity = s
}
makeRequest := func(id string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/public/d/"+id+"?%3Aid="+id, nil)
w := httptest.NewRecorder()
pub.handleDownloads(w, r)
return w
}
It("sets a Content-Disposition filename from the share description", func() {
shareIs(&model.Share{ID: "abc123", Description: "My Mixtape", Downloadable: true})
w := makeRequest("abc123")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="My Mixtape.zip"`))
Expect(w.Header().Get("Content-Type")).To(Equal("application/zip"))
Expect(archiver.called).To(BeTrue())
Expect(w.Body.String()).To(Equal("zip-contents"))
})
It("falls back to the share ID when there is no description", func() {
shareIs(&model.Share{ID: "abc123", Downloadable: true})
w := makeRequest("abc123")
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="abc123.zip"`))
})
It("sanitizes characters that are unsafe in a filename", func() {
shareIs(&model.Share{ID: "abc123", Description: `AC/DC: Live, 1979`, Downloadable: true})
w := makeRequest("abc123")
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="AC_DC_ Live_ 1979.zip"`))
})
It("returns 403 without invoking the archiver when the share is not downloadable", func() {
shareIs(&model.Share{ID: "abc123", Description: "No Download", Downloadable: false})
w := makeRequest("abc123")
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(archiver.called).To(BeFalse())
Expect(w.Header().Get("Content-Disposition")).To(BeEmpty())
})
It("returns 404 when the share does not exist", func() {
shareIs(&model.Share{ID: "other", Downloadable: true})
w := makeRequest("missing")
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(archiver.called).To(BeFalse())
})
It("returns 410 when the share has expired", func() {
shareIs(&model.Share{ID: "abc123", Downloadable: true, ExpiresAt: new(time.Now().Add(-time.Hour))})
w := makeRequest("abc123")
Expect(w.Code).To(Equal(http.StatusGone))
Expect(archiver.called).To(BeFalse())
})
It("returns 500 when the share lookup fails", func() {
shareRepo.Error = errors.New("db error")
w := makeRequest("abc123")
Expect(w.Code).To(Equal(http.StatusInternalServerError))
Expect(archiver.called).To(BeFalse())
})
})

View File

@ -68,7 +68,7 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
}
func decodeArtworkID(tokenString string) (model.ArtworkID, error) {
token, err := auth.TokenAuth.Decode(tokenString)
token, err := auth.PublicTokenAuth.Decode(tokenString)
if err != nil {
return model.ArtworkID{}, err
}

View File

@ -9,7 +9,7 @@ import (
var _ = Describe("decodeArtworkID", func() {
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil)
})
It("fails to decode an invalid token", func() {

View File

@ -111,8 +111,8 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share {
// admin flag) and grants access to nothing beyond the share it belongs to; the
// stream handler still verifies the share exists, is unexpired, and that the
// track is actually a member of it. An attacker who can forge these tokens
// necessarily already holds the signing secret, which also signs real user
// sessions, so that scenario is out of scope for the share boundary specifically.
// necessarily already holds the public-link signing secret, a full-server
// compromise that is out of scope for the share boundary specifically.
func encodeMediafileShare(s model.Share, id string) string {
claims := auth.Claims{
ID: id,

View File

@ -107,7 +107,7 @@ func shareContainsTrack(share *model.Share, mediaFileID string) bool {
// public-share capability, not an auth credential; see encodeMediafileShare for
// why a JWT is used here.
func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
c, err := auth.Validate(tokenString)
c, err := auth.ValidatePublic(tokenString)
if err != nil {
return shareTrackInfo{}, err
}

View File

@ -29,7 +29,7 @@ func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream
var _ = Describe("decodeStreamInfo", func() {
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
})
It("decodes a valid token with all fields", func() {
@ -81,7 +81,7 @@ var _ = Describe("decodeStreamInfo", func() {
var _ = Describe("encodeMediafileShare", func() {
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
})
It("includes the share ID in the token", func() {
@ -113,7 +113,7 @@ var _ = Describe("handleStream", func() {
var pub *Router
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
ds = &tests.MockDataStore{}
shareRepo = &tests.MockShareRepo{}
ds.MockedShare = shareRepo

View File

@ -381,13 +381,14 @@ func (api *Router) GetSimilarSongs2(r *http.Request) (*responses.Subsonic, error
func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
p := req.Params(r)
id, idErr := p.String("id")
artist, err := p.String("artist")
if err != nil {
if err != nil && idErr != nil {
return nil, err
}
count := p.IntOr("count", 50)
songs, err := api.provider.TopSongs(ctx, artist, count)
songs, err := api.provider.TopSongs(ctx, artist, id, count)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, err
}

View File

@ -330,7 +330,7 @@ func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer)
return model.ErrNotFound
}
func (n noopArchiver) ZipShare(context.Context, string, io.Writer) error {
func (n noopArchiver) ZipShare(context.Context, *model.Share, io.Writer) error {
return model.ErrNotFound
}
@ -353,7 +353,7 @@ func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFil
return nil, nil
}
func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) {
func (n noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) {
return nil, nil
}

View File

@ -13,6 +13,15 @@ var _ = Describe("Browsing Endpoints", func() {
setupTestDB()
})
getBeatlesId := func() string {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
return artists[0].ID
}
Describe("getMusicFolders", func() {
It("returns the configured music library", func() {
resp := doReq("getMusicFolders")
@ -85,12 +94,7 @@ var _ = Describe("Browsing Endpoints", func() {
Describe("getMusicDirectory", func() {
It("returns an artist directory with its albums as children", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
beatlesID := artists[0].ID
beatlesID := getBeatlesId()
resp := doReq("getMusicDirectory", "id", beatlesID)
@ -125,12 +129,7 @@ var _ = Describe("Browsing Endpoints", func() {
Describe("getArtist", func() {
It("returns artist with albums in ID3 format", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
beatlesID := artists[0].ID
beatlesID := getBeatlesId()
resp := doReq("getArtist", "id", beatlesID)
@ -141,12 +140,7 @@ var _ = Describe("Browsing Endpoints", func() {
})
It("returns album names for the artist", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
beatlesID := artists[0].ID
beatlesID := getBeatlesId()
resp := doReq("getArtist", "id", beatlesID)
@ -381,13 +375,7 @@ var _ = Describe("Browsing Endpoints", func() {
Describe("getArtistInfo", func() {
It("returns artist info for a valid artist", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
beatlesID := artists[0].ID
beatlesID := getBeatlesId()
resp := doReq("getArtistInfo", "id", beatlesID)
Expect(resp.Status).To(Equal(responses.StatusOK))
@ -397,13 +385,7 @@ var _ = Describe("Browsing Endpoints", func() {
Describe("getArtistInfo2", func() {
It("returns artist info2 for a valid artist", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
beatlesID := artists[0].ID
beatlesID := getBeatlesId()
resp := doReq("getArtistInfo2", "id", beatlesID)
Expect(resp.Status).To(Equal(responses.StatusOK))
@ -426,6 +408,28 @@ var _ = Describe("Browsing Endpoints", func() {
Expect(resp.TopSongs).ToNot(BeNil())
Expect(resp.TopSongs.Song).To(BeEmpty())
})
It("returns an error if no arguments are specified", func() {
resp := doReq("getTopSongs")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.TopSongs).To(BeNil())
})
It("returns a response only by id", func() {
beatlesID := getBeatlesId()
resp := doReq("getTopSongs", "id", beatlesID)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TopSongs).ToNot(BeNil())
})
It("returns a response when both id and name are included", func() {
beatlesID := getBeatlesId()
resp := doReq("getTopSongs", "id", beatlesID, "artist", "The Beatles")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TopSongs).ToNot(BeNil())
})
})
Describe("getSimilarSongs", func() {

View File

@ -22,6 +22,7 @@ var _ = Describe("helpers", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test public secret"), nil)
})
Describe("fakePath", func() {

View File

@ -15,6 +15,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "indexBasedQueue", Versions: []int32{1}},
{Name: "transcoding", Versions: []int32{1}},
{Name: "playbackReport", Versions: []int32{1}},
{Name: "topSongsByArtistId", Versions: []int32{1}},
}
if api.sonic != nil && api.sonic.HasProvider() {
extensions = append(extensions, responses.OpenSubsonicExtension{

View File

@ -55,13 +55,14 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(6),
HaveLen(7),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
))
Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
@ -85,7 +86,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(7),
HaveLen(8),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
@ -93,6 +94,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
))
})
})

View File

@ -5,6 +5,6 @@
<p id="errorMessageDescription" style="text-align:center;font-size:21px;font-family:arial;margin-top:28px">
It looks like we are having trouble connecting.
<br/>
Please check your internet connection and try again.</p>
Please check your internet connection, or try again in a moment.</p>
</body>
</html>

View File

@ -1,7 +1,6 @@
import { jwtDecode } from 'jwt-decode'
import { baseUrl } from './utils'
import config from './config'
import { removeHomeCache } from './utils/removeHomeCache'
// config sent from server may contain authentication info, for example when the user is authenticated
// by a reverse proxy request header
@ -49,7 +48,6 @@ const authProvider = {
storeAuthenticationInfo(response)
// Avoid "going to create admin" dialog after logout/login without a refresh
config.firstTime = false
removeHomeCache()
return response
})
.catch((error) => {

View File

@ -3,7 +3,6 @@ import { v4 as uuidv4 } from 'uuid'
import { baseUrl } from '../utils'
import config from '../config'
import { jwtDecode } from 'jwt-decode'
import { removeHomeCache } from '../utils/removeHomeCache'
const customAuthorizationHeader = 'X-ND-Authorization'
export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id'
@ -27,7 +26,6 @@ const httpClient = (url, options = {}) => {
localStorage.setItem('userId', decoded.uid)
// Avoid going to create admin dialog after logout/login without a refresh
config.firstTime = false
removeHomeCache()
}
return response
})

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