Merge branch 'master' into lyrics-plugin-request-coalescing

This commit is contained in:
Yuuta 2026-09-10 03:25:19 +03:00 • committed by GitHub
commit 795d5cf293
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
241 changed files with 5546 additions and 2204 deletions

View file

@ -1,6 +1,11 @@
package plugins
import "slices"
import (
"regexp"
"slices"
"github.com/navidrome/navidrome/core/agents"
)
// Capability represents a plugin capability type.
// Capabilities are detected by checking which functions a plugin exports.
@ -39,3 +44,25 @@ func detectCapabilities(plugin functionExistsChecker) []Capability {
func hasCapability(capabilities []Capability, cap Capability) bool {
return slices.Contains(capabilities, cap)
}
// retryLaterRe matches one capability's `<capability>(retry_later[:seconds])` token, which is
// all a plugin fault carries back across the WASM boundary. The capability is part of the
// pattern, so another capability's token in the same message cannot mask this one. The leading
// \b keeps a superstring like `useragent(retry_later)` from matching `agent`.
var (
agentRetryLaterRe = retryLaterRe("agent")
scrobblerRetryLaterRe = retryLaterRe("scrobbler")
)
func retryLaterRe(capability string) *regexp.Regexp {
return regexp.MustCompile(`\b` + capability + `\(retry_later(?::(\d+))?\)`)
}
// parseRetryLater reports whether msg carries the capability's retry_later token, with its delay.
func parseRetryLater(re *regexp.Regexp, msg string) (*agents.RetryLaterError, bool) {
m := re.FindStringSubmatch(msg)
if m == nil {
return nil, false
}
return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(m[1])}, true
}

View file

@ -224,3 +224,15 @@ type SimilarSongsResponse struct {
// Songs is the list of similar songs.
Songs []types.SongRef `json:"songs"`
}
// MetadataAgentError represents an error type for metadata agent operations.
type MetadataAgentError string
const (
// MetadataAgentErrorRetryLater indicates the provider is throttling; retry later.
// Append ":<seconds>" inside the parentheses to request a specific delay.
MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)"
)
// Error implements the error interface for MetadataAgentError.
func (e MetadataAgentError) Error() string { return string(e) }

View file

@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
. "github.com/onsi/ginkgo/v2"
@ -32,6 +33,9 @@ var _ = Describe("ndpgen CLI", Ordered, func() {
// Build the ndpgen binary
ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test")
if runtime.GOOS == "windows" {
ndpgenBin += ".exe" // Windows refuses to exec a file without it
}
cmd := exec.Command("go", "build", "-o", ndpgenBin, ".")
cmd.Dir = mustGetWd(GinkgoT())
output, err := cmd.CombinedOutput()

View file

@ -108,7 +108,7 @@ Generate boilerplate from a schema:
# Install XTP: https://docs.xtp.dylibso.com/docs/cli
xtp plugin init \
--schema-file ../schemas/metadata_agent.yaml \
--schema-file ../capabilities/metadata_agent.yaml \
--template go \
--path ./my-plugin \
--name my-plugin
@ -119,7 +119,7 @@ xtp plugin build
zip -j my-plugin.ndp manifest.json dist/plugin.wasm
```
Available schemas in [../schemas/](../schemas/):
Available schemas in [../capabilities/](../capabilities/):
- `metadata_agent.yaml` – Artist/album metadata
- `scrobbler.yaml` – Scrobbling integration
- `lifecycle.yaml` – Init callbacks

View file

@ -14,24 +14,24 @@ func newArtworkService() host.ArtworkService {
return &artworkServiceImpl{}
}
func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, id string, size int32) (string, error) {
func (a *artworkServiceImpl) GetArtistUrl(ctx context.Context, id string, size int32) (string, error) {
artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: id}
return publicurl.ImageURL(nil, artID, int(size)), nil
return publicurl.ImageURL(ctx, artID, int(size)), nil
}
func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, id string, size int32) (string, error) {
func (a *artworkServiceImpl) GetAlbumUrl(ctx context.Context, id string, size int32) (string, error) {
artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: id}
return publicurl.ImageURL(nil, artID, int(size)), nil
return publicurl.ImageURL(ctx, artID, int(size)), nil
}
func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, id string, size int32) (string, error) {
func (a *artworkServiceImpl) GetTrackUrl(ctx context.Context, id string, size int32) (string, error) {
artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: id}
return publicurl.ImageURL(nil, artID, int(size)), nil
return publicurl.ImageURL(ctx, artID, int(size)), nil
}
func (a *artworkServiceImpl) GetPlaylistUrl(_ context.Context, id string, size int32) (string, error) {
func (a *artworkServiceImpl) GetPlaylistUrl(ctx context.Context, id string, size int32) (string, error) {
artID := model.ArtworkID{Kind: model.KindPlaylistArtwork, ID: id}
return publicurl.ImageURL(nil, artID, int(size)), nil
return publicurl.ImageURL(ctx, artID, int(size)), nil
}
var _ host.ArtworkService = (*artworkServiceImpl)(nil)

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (
@ -16,6 +14,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -122,7 +121,7 @@ var _ = Describe("ArtworkService", Ordered, func() {
Size: size,
}
inputBytes, _ := json.Marshal(input)
_, outputBytes, err := instance.Call("nd_test_artwork", inputBytes)
_, outputBytes, err := instance.CallWithContext(ctx, "nd_test_artwork", inputBytes)
if err != nil {
return "", err
}
@ -189,6 +188,14 @@ var _ = Describe("ArtworkService", Ordered, func() {
Expect(artID.ID).To(Equal("al-456"))
})
It("uses the address of the request that triggered the plugin", func() {
ctx := request.WithServerAddress(GinkgoT().Context(), "https", "music.example.com")
url, err := callTestArtwork(ctx, "track", "mf-789", 300)
Expect(err).ToNot(HaveOccurred())
Expect(url).To(HavePrefix("https://music.example.com/share/img/"))
})
It("should handle unknown artwork type", func() {
_, err := callTestArtwork(GinkgoT().Context(), "unknown", "id-123", 0)
Expect(err).To(HaveOccurred())

View file

@ -2,6 +2,7 @@ package plugins
import (
"context"
"runtime"
"time"
"github.com/jellydator/ttlcache/v3"
@ -29,11 +30,18 @@ func newCacheService(pluginName string) *cacheServiceImpl {
// Start the janitor goroutine to clean up expired entries
go cache.Start()
return &cacheServiceImpl{
svc := &cacheServiceImpl{
pluginName: pluginName,
cache: cache,
defaultTTL: defaultCacheTTL,
}
// Automatic cleanup to prevent goroutine leak when the service is garbage collected
runtime.AddCleanup(svc, func(ttlCache *ttlcache.Cache[string, any]) {
ttlCache.Stop()
}, cache)
return svc
}
// getTTL converts seconds to a duration, using default if 0 or negative

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (
@ -8,9 +6,11 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
"github.com/navidrome/navidrome/conf"
@ -599,3 +599,35 @@ var _ = Describe("CacheService Integration", Ordered, func() {
})
})
})
var _ = Describe("newCacheService", func() {
// The suite above leaves goroutines winding down, so settle before sampling.
settledBaseline := func() int {
var n int
Eventually(func() int {
runtime.GC()
prev := n
n = runtime.NumGoroutine()
return n - prev
}).WithTimeout(10 * time.Second).WithPolling(20 * time.Millisecond).Should(BeZero())
return n
}
It("stops the janitor goroutine once the service is unreachable", func() {
const numServices = 5
baseline := settledBaseline()
func() {
services := make([]*cacheServiceImpl, 0, numServices)
for i := range numServices {
services = append(services, newCacheService(fmt.Sprintf("plugin_%d", i)))
}
Expect(runtime.NumGoroutine()).To(BeNumerically(">=", baseline+numServices),
"expected one janitor goroutine per cache service")
}()
Eventually(func() int { runtime.GC(); return runtime.NumGoroutine() }).
WithTimeout(10*time.Second).WithPolling(20*time.Millisecond).
Should(BeNumerically("<=", baseline), "janitor goroutines leaked")
})
})

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -46,24 +46,21 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI
pluginName: pluginName,
requiredHosts: requiredHosts,
}
svc.client = &http.Client{
Transport: httpclient.NewTransport(nil),
// Timeout is set per-request via context deadline, not here.
// CheckRedirect validates hosts and enforces redirect limits.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if req.Context().Value(noFollowRedirectsKey) != nil {
return http.ErrUseLastResponse
}
if len(via) >= httpClientMaxRedirects {
log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via))
return http.ErrUseLastResponse
}
if err := svc.validateHost(req.Context(), req.URL.Host); err != nil {
log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err)
return err
}
return nil
},
// No client timeout: it is set per-request via context deadline.
svc.client = httpclient.New(0)
svc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.Context().Value(noFollowRedirectsKey) != nil {
return http.ErrUseLastResponse
}
if len(via) >= httpClientMaxRedirects {
log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via))
return http.ErrUseLastResponse
}
if err := svc.validateHost(req.Context(), req.URL.Host); err != nil {
log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err)
return err
}
return nil
}
return svc
}

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -243,11 +243,7 @@ func (s *webSocketServiceImpl) getConnection(connectionID string) (*wsConnection
}
func (s *webSocketServiceImpl) isHostAllowed(host string) bool {
// Strip port from host if present
hostWithoutPort := host
if idx := strings.LastIndex(host, ":"); idx != -1 {
hostWithoutPort = host[:idx]
}
hostWithoutPort := extractHostname(host)
for _, pattern := range s.requiredHosts {
if matchHostPattern(pattern, hostWithoutPort) {

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (
@ -144,20 +142,18 @@ var _ = Describe("WebSocketService", Ordered, func() {
Expect(allowed).To(BeFalse())
})
It("should strip port before checking host", func() {
// Implementation strips port before matching against patterns
// test-websocket manifest has "localhost:*" which matches "localhost"
// after port stripping
// Note: The port wildcard pattern isn't actually implemented, but
// since port is stripped, "localhost:*" is compared against "localhost"
// which won't match. To make localhost work, we'd need exact "localhost"
// in the allowed hosts list.
// Testing that port is properly stripped
// The pattern "localhost:*" won't match "localhost" due to exact match
allowed := testService.isHostAllowed("localhost:8080")
Expect(allowed).To(BeFalse())
})
DescribeTable("should match against the host with its port stripped",
func(allowed []string, host string, expected bool) {
svc := &webSocketServiceImpl{requiredHosts: allowed}
Expect(svc.isHostAllowed(host)).To(Equal(expected))
},
Entry("hostname with port", []string{"example.com"}, "example.com:8080", true),
Entry("IPv6 with port", []string{"::1"}, "[::1]:8080", true),
Entry("IPv6 without port", []string{"::1"}, "[::1]", true),
Entry("host not in the list", []string{"::2"}, "[::1]:8080", false),
// "localhost:*" is matched against the stripped "localhost", so it never hits
Entry("port wildcards are not supported", []string{"localhost:*"}, "localhost:8080", false),
)
})
Describe("Connection Management", func() {

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -56,6 +56,9 @@ func agentErr(err error) error {
if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) {
return errors.Join(agents.ErrNotFound, err)
}
if retryLater, ok := parseRetryLater(agentRetryLaterRe, err.Error()); ok {
return errors.Join(retryLater, err)
}
return err
}

View file

@ -1,10 +1,9 @@
//go:build !windows
package plugins
import (
"errors"
"fmt"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/plugins/capabilities"
@ -31,6 +30,31 @@ var _ = Describe("agentErr", func() {
Entry("a non-zero exit is a fault",
errors.New("plugin call exited with code 1"), false),
)
DescribeTable("agentErr retry-later",
func(msg string, wantDelay time.Duration) {
err := agentErr(errors.New(msg))
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
retry, _ := errors.AsType[*agents.RetryLaterError](err)
d := retry.RetryIn
Expect(d).To(Equal(wantDelay))
},
Entry("bare token", "agent(retry_later)", time.Duration(0)),
Entry("with seconds", "agent(retry_later:120)", 120*time.Second),
Entry("capped at 1h", "agent(retry_later:999999)", time.Hour),
// Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s.
Entry("capped before it can overflow", "agent(retry_later:18446744074)", time.Hour),
)
It("leaves other plugin errors untouched", func() {
orig := errors.New("some plugin failure")
Expect(agentErr(orig)).To(Equal(orig))
})
It("does not treat a superstring token as a throttle", func() {
orig := errors.New("useragent(retry_later)")
Expect(agentErr(orig)).To(Equal(orig))
})
})
var _ = Describe("MetadataAgent", Ordered, func() {

View file

@ -2,8 +2,11 @@ package plugins
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@ -266,6 +269,14 @@ func (h *testZipHelper) close() error {
// createTestPackage creates an .ndp package file from a manifest and wasm bytes.
// This is primarily used for testing.
func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) error {
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("marshaling manifest: %w", err)
}
return writeNdp(ndpPath, bytes.NewReader(manifestBytes), bytes.NewReader(wasmBytes))
}
func writeNdp(ndpPath string, manifest, wasm io.Reader) error {
f, err := os.Create(ndpPath)
if err != nil {
return fmt.Errorf("creating package file: %w", err)
@ -273,30 +284,23 @@ func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) err
defer f.Close()
zw := zip.NewWriter(f)
defer zw.Close()
// Write manifest.json
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("marshaling manifest: %w", err)
add := func(name string, r io.Reader) error {
w, err := zw.Create(name)
if err != nil {
return fmt.Errorf("creating %s in package: %w", name, err)
}
if _, err := io.Copy(w, r); err != nil {
return fmt.Errorf("writing %s: %w", name, err)
}
return nil
}
mw, err := zw.Create(manifestFileName)
if err != nil {
return fmt.Errorf("creating manifest in zip: %w", err)
// Entry order is fixed: the loader hashes the package bytes, so they must
// be reproducible across rebuilds.
if err := add(manifestFileName, manifest); err != nil {
return err
}
if _, err := mw.Write(manifestBytes); err != nil {
return fmt.Errorf("writing manifest: %w", err)
if err := add(wasmFileName, wasm); err != nil {
return err
}
// Write plugin.wasm
ww, err := zw.Create(wasmFileName)
if err != nil {
return fmt.Errorf("creating wasm in zip: %w", err)
}
if _, err := ww.Write(wasmBytes); err != nil {
return fmt.Errorf("writing wasm: %w", err)
}
return nil
return errors.Join(zw.Close(), f.Close())
}

View file

@ -18,6 +18,18 @@ type ArtistRef = types.ArtistRef
// Deprecated: use types.SongRef.
type SongRef = types.SongRef
// MetadataAgentError represents an error type for metadata agent operations.
type MetadataAgentError string
const (
// MetadataAgentErrorRetryLater indicates the provider is throttling; retry later.
// Append ":<seconds>" inside the parentheses to request a specific delay.
MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)"
)
// Error implements the error interface for MetadataAgentError.
func (e MetadataAgentError) Error() string { return string(e) }
// AlbumImagesResponse is the response for GetAlbumImages.
type AlbumImagesResponse struct {
// Images is the list of album images.

View file

@ -16,6 +16,18 @@ type ArtistRef = types.ArtistRef
// Deprecated: use types.SongRef.
type SongRef = types.SongRef
// MetadataAgentError represents an error type for metadata agent operations.
type MetadataAgentError string
const (
// MetadataAgentErrorRetryLater indicates the provider is throttling; retry later.
// Append ":<seconds>" inside the parentheses to request a specific delay.
MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)"
)
// Error implements the error interface for MetadataAgentError.
func (e MetadataAgentError) Error() string { return string(e) }
// AlbumImagesResponse is the response for GetAlbumImages.
type AlbumImagesResponse struct {
// Images is the list of album images.

View file

@ -24,6 +24,11 @@ pub type ArtistRef = nd_pdk_types::ArtistRef;
#[deprecated(note = "use nd_pdk::types::SongRef")]
pub type SongRef = nd_pdk_types::SongRef;
/// MetadataAgentError represents an error type for metadata agent operations.
pub type MetadataAgentError = &'static str;
/// MetadataAgentErrorRetryLater indicates the provider is throttling; retry later.
/// Append ":<seconds>" inside the parentheses to request a specific delay.
pub const METADATA_AGENT_ERROR_RETRY_LATER: MetadataAgentError = "agent(retry_later)";
/// AlbumImagesResponse is the response for GetAlbumImages.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]

View file

@ -84,4 +84,4 @@ Rust plugins must be compiled to WebAssembly:
cargo build --target wasm32-wasip1 --release
```
See the [webhook-rs](../../examples/webhook-rs/) example for a complete plugin implementation.
See the [webhook-rs](../../../examples/webhook-rs/) example for a complete plugin implementation.

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (
@ -9,12 +7,10 @@ import (
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@ -25,7 +21,10 @@ import (
. "github.com/onsi/gomega"
)
const testDataDir = "plugins/testdata"
const (
testDataDir = "plugins/testdata"
wazeroCacheDir = ".wazero-cache"
)
// Shared test state initialized in BeforeSuite
var (
@ -36,38 +35,17 @@ var (
func TestPlugins(t *testing.T) {
tests.Init(t, false)
buildTestPlugins(t, testDataDir)
// Create a shared wazero compilation cache directory.
// All test managers will point CacheFolder here so that WASM compilation
// is done once per binary and then reused from disk cache.
sharedCacheDir, err := os.MkdirTemp("", "plugins-shared-cache-*")
if err != nil {
t.Fatalf("Failed to create shared cache dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(sharedCacheDir) })
// Set CacheFolder globally so all tests (including those using
// configtest.SetupConfig) inherit it without needing to set it manually.
conf.Server.CacheFolder = conf.NewDir(sharedCacheDir)
// Set globally so tests using configtest.SetupConfig inherit it. The cache
// persists between runs; entries are content-addressed, so a stale one only misses.
conf.Server.CacheFolder = conf.NewDir(filepath.Join(testDataDir, wazeroCacheDir))
conf.Server.Plugins.CacheSize = "1GB" // the default evicts the cache mid-run
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Plugins Suite")
}
func buildTestPlugins(t *testing.T, path string) {
t.Helper()
start := time.Now()
t.Logf("[BeforeSuite] Current working directory: %s", path)
cmd := exec.Command("make", "-C", path)
out, err := cmd.CombinedOutput()
t.Logf("[BeforeSuite] Make output: %s elapsed: %s", string(out), time.Since(start))
if err != nil {
t.Fatalf("Failed to build test plugins: %v", err)
}
}
// createTestManager creates a new plugin Manager with the given plugin config.
// It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager.
// Returns the manager, temp directory path, and a cleanup function.
@ -151,7 +129,10 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s
return manager, tmpDir
}
var _ = BeforeSuite(func() {
var _ = SynchronizedBeforeSuite(func() {
// Build once: the testdata Makefile is not safe to run concurrently.
buildTestPlugins(testDataDir)
}, func() {
// Get testdata directory (where test plugin .ndp packages live)
_, currentFile, _, ok := runtime.Caller(0)
Expect(ok).To(BeTrue())

View file

@ -1,23 +0,0 @@
//go:build windows
package plugins
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Runs the subset of plugin specs compiled on Windows (files without the
// //go:build !windows tag): capabilities, manager_cache, manager_plugin,
// manifest, package. WASM-runtime-dependent specs live in !windows-tagged
// files and aren't reached here.
func TestPlugins(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Plugins Suite")
}

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (
@ -134,7 +132,9 @@ var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func()
// 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())
if err := os.Symlink(outsideDir, filepath.Join(libraryDir, "planted")); err != nil {
Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges
}
out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"})

View file

@ -174,11 +174,12 @@ func mapScrobblerError(err error) error {
return nil
}
errMsg := err.Error()
retryLater, isRetryLater := parseRetryLater(scrobblerRetryLaterRe, errMsg)
switch {
case strings.Contains(errMsg, capabilities.ScrobblerErrorNotAuthorized.Error()):
return scrobbler.ErrNotAuthorized
case strings.Contains(errMsg, capabilities.ScrobblerErrorRetryLater.Error()):
return scrobbler.ErrRetryLater
case isRetryLater:
return retryLater
case strings.Contains(errMsg, capabilities.ScrobblerErrorUnrecoverable.Error()):
return scrobbler.ErrUnrecoverable
default:

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (
@ -7,6 +5,7 @@ import (
"errors"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -362,4 +361,25 @@ var _ = Describe("mapScrobblerError", func() {
err := mapScrobblerError(errors.New("some unknown error"))
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
DescribeTable("mapScrobblerError retry-later",
func(msg string, wantDelay time.Duration) {
err := mapScrobblerError(errors.New(msg))
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
retry, _ := errors.AsType[*agents.RetryLaterError](err)
d := retry.RetryIn
Expect(d).To(Equal(wantDelay))
},
Entry("bare token", "scrobbler(retry_later)", time.Duration(0)),
Entry("with seconds", "scrobbler(retry_later:30)", 30*time.Second),
Entry("capped at 1h", "scrobbler(retry_later:999999)", time.Hour),
// Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s.
Entry("capped before it can overflow", "scrobbler(retry_later:18446744074)", time.Hour),
Entry("wrapped in context", "plugin xyz: scrobbler(retry_later:5)", 5*time.Second),
)
It("still maps unknown errors to unrecoverable", func() {
err := mapScrobblerError(errors.New("scrobbler(retry_later_garbage"))
Expect(errors.Is(err, scrobbler.ErrUnrecoverable)).To(BeTrue())
})
})

View file

@ -1,5 +1,3 @@
//go:build !windows
package plugins
import (

View file

@ -10,22 +10,26 @@ all: $(PLUGINS:%=%.ndp)
clean:
rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm)
rm -rf .wazero-cache $(PLUGINS:%=%.stage)
# PDK source files that trigger rebuild when changed (recursive)
PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null)
# Build the .ndp package (zip containing manifest.json + plugin.wasm)
# Stage under a per-target name: a shared plugin.wasm breaks concurrent builds.
%.ndp: %.wasm %/manifest.json
@rm -f $@
@cp $< plugin.wasm
zip -j $@ $*/manifest.json plugin.wasm
@rm -f plugin.wasm
@rm -rf $*.stage && mkdir -p $*.stage
@cp $< $*.stage/plugin.wasm
zip -j $@ $*/manifest.json $*.stage/plugin.wasm
@rm -rf $*.stage
@mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp
# Build the wasm binary
# Build the wasm binary. -buildvcs=false keeps the bytes stable across commits, so
# the test suite's wazero compilation cache still hits after a rebuild.
%.wasm: %/*.go %/go.mod $(PDK_SOURCES)
ifdef TINYGO
cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ .
else
cd $* && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../$@ .
cd $* && GOOS=wasip1 GOARCH=wasm go build -buildvcs=false -buildmode=c-shared -o ../$@ .
endif

View file

@ -0,0 +1,119 @@
package plugins
import (
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"sync"
"time"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// buildTestPlugins packages every test plugin under dir, replicating
// `make -C plugins/testdata` without needing make or zip on the PATH.
func buildTestPlugins(dir string) {
start := time.Now()
built, err := buildPackages(dir)
fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s: %v\n", time.Since(start), built)
Expect(err).ToNot(HaveOccurred(), "failed to build test plugins")
}
func buildPackages(dir string) ([]string, error) {
mods, err := filepath.Glob(filepath.Join(dir, "*", "go.mod"))
if err != nil || len(mods) == 0 {
return nil, err
}
pdkTime, err := newestModTime(filepath.Join(dir, "..", "pdk", "go"))
if err != nil {
return nil, err
}
built := make([]string, len(mods))
errs := make([]error, len(mods))
build := func(i int) {
pluginDir := filepath.Dir(mods[i])
var rebuilt bool
if rebuilt, errs[i] = buildPackage(pluginDir, pdkTime); rebuilt {
built[i] = filepath.Base(pluginDir)
}
}
// The first build populates the wasip1 stdlib and PDK objects every plugin
// shares; fanning out before it lands makes each one compile them again.
build(0)
var wg sync.WaitGroup
for i := range mods[1:] {
wg.Go(func() { build(i + 1) })
}
wg.Wait()
return slices.DeleteFunc(built, func(name string) bool { return name == "" }), errors.Join(errs...)
}
func buildPackage(dir string, pdkTime time.Time) (bool, error) {
sourceTime, err := newestModTime(dir)
if err != nil {
return false, err
}
pkg := dir + PackageExtension
if info, err := os.Stat(pkg); err == nil && info.ModTime().After(utils.TimeNewest(sourceTime, pdkTime)) {
return false, nil
}
wasm, err := filepath.Abs(pkg + ".build.wasm")
if err != nil {
return false, err
}
defer os.Remove(wasm)
// -buildvcs=false keeps the bytes stable across commits, so the suite's
// wazero compilation cache still hits after a rebuild.
cmd := exec.Command("go", "build", "-buildvcs=false", "-buildmode=c-shared", "-o", wasm, ".")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
if out, err := cmd.CombinedOutput(); err != nil {
return false, fmt.Errorf("building %s: %w\n%s", dir, err, out)
}
tmp := pkg + ".build.ndp"
defer os.Remove(tmp)
if err := packageFiles(tmp, filepath.Join(dir, manifestFileName), wasm); err != nil {
return false, fmt.Errorf("packaging %s: %w", dir, err)
}
return true, os.Rename(tmp, pkg)
}
func packageFiles(pkg, manifest, wasm string) error {
m, err := os.Open(manifest)
if err != nil {
return err
}
defer m.Close()
w, err := os.Open(wasm)
if err != nil {
return err
}
defer w.Close()
return writeNdp(pkg, m, w)
}
func newestModTime(root string) (time.Time, error) {
var newest time.Time
err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
info, err := d.Info()
if err != nil {
return err
}
newest = utils.TimeNewest(newest, info.ModTime())
return nil
})
return newest, err
}