mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-22 17:27:17 +02:00
Merge branch 'master' into lyrics-plugin-request-coalescing
This commit is contained in:
commit
795d5cf293
241 changed files with 5546 additions and 2204 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
|
|
|||
14
plugins/testdata/Makefile
vendored
14
plugins/testdata/Makefile
vendored
|
|
@ -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
|
||||
119
plugins/testdata_builder_test.go
Normal file
119
plugins/testdata_builder_test.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue