Merge branch 'master' into add-scrobble-repo-apis

This commit is contained in:
Deluan Quintão 2026-07-12 20:22:24 -04:00 committed by GitHub
commit 1a8d3e2b82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 291 additions and 80 deletions

View File

@ -99,16 +99,20 @@ jobs:
validate-migrations:
name: Validate DB migrations
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
# PR-only gate is at step level: a job-level skip would propagate through
# the needs chain (actions/runner#491) and skip all release jobs on tag pushes.
steps:
- uses: actions/checkout@v7
if: github.event_name == 'pull_request'
with:
fetch-depth: 0
# Refresh the base branch so the check compares against its CURRENT tip,
# not the (possibly stale) commit the PR was opened against.
- name: Fetch latest base branch
if: github.event_name == 'pull_request'
run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}"
- name: Validate migration ordering and naming
if: github.event_name == 'pull_request'
env:
BASE_REF: origin/${{ github.event.pull_request.base.ref }}
run: ./.github/workflows/validate-migrations.sh
@ -275,10 +279,6 @@ jobs:
build:
name: Build
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
# validate-migrations only runs on pull_request, so it is "skipped" on push/tag
# builds. Run Build unless a dependency actually failed — a *skipped* dependency
# (the migration check on non-PR events) must not block release builds.
if: ${{ !cancelled() && !failure() }}
strategy:
matrix:
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]

View File

@ -113,6 +113,20 @@ var _ = Describe("parseNSP", func() {
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
})
It("rejects a NSP that mixes top-level 'any' and 'all' instead of silently dropping a group", func() {
nsp := `{
"name": "Overplayed Favorites",
"any": [{"inPlaylist": {"path": "most-played-favorites.nsp"}}],
"all": [{"notInPlaylist": {"path": "favorites-not-played-in-4-yrs.nsp"}}],
"sort": "playCount, lastPlayed"
}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
Expect(err.Error()).To(And(ContainSubstring("all"), ContainSubstring("any")))
})
It("gracefully handles non-string name field", func() {
nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Name: "Original"}

View File

@ -43,14 +43,17 @@ func normalizeSourceSampleRate(sampleRate int, codec string) int {
return sampleRate
}
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
// what ffmpeg produces). For other codecs, returns the depth unchanged.
func normalizeSourceBitDepth(bitDepth int, codec string) int {
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
// targetBitDepth returns the bit depth for a transcoded stream: 0 for lossy
// targets (they have no PCM bit depth), otherwise the source depth, with DSD
// adjusted to the 24-bit PCM that ffmpeg produces.
func targetBitDepth(srcBitDepth int, srcCodec string, targetIsLossless bool) int {
if !targetIsLossless {
return 0
}
if strings.EqualFold(srcCodec, "dsd") && srcBitDepth == 1 {
return 24
}
return bitDepth
return srcBitDepth
}
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs

View File

@ -269,7 +269,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai
Codec: strings.ToLower(profile.AudioCodec),
SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec),
Channels: src.Channels,
BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec),
BitDepth: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless),
IsLossless: targetIsLossless,
}
if ts.Codec == "" {

View File

@ -656,6 +656,44 @@ var _ = Describe("Decider", func() {
Expect(decision.TargetBitDepth).To(Equal(24))
})
It("omits bit depth when transcoding to a lossy format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.BitDepth).To(BeZero())
Expect(decision.TargetBitDepth).To(BeZero())
})
It("ignores audioBitdepth limitation when transcoding to a lossy format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "opus",
Limitations: []Limitation{
{Name: LimitationAudioBitdepth, Comparison: ComparisonGreaterThanEqual, Values: []string{"32"}, Required: true},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.BitDepth).To(BeZero())
})
It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
@ -695,9 +733,9 @@ var _ = Describe("Decider", func() {
// DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
Expect(decision.TargetSampleRate).To(Equal(48000))
// DSD 1-bit → 24-bit PCM
Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
Expect(decision.TargetBitDepth).To(Equal(24))
// MP3 is lossy: no bit depth on the transcoded stream
Expect(decision.TranscodeStream.BitDepth).To(BeZero())
Expect(decision.TargetBitDepth).To(BeZero())
})
It("converts DSD sample rate for FLAC target without codec limit", func() {

View File

@ -103,21 +103,26 @@ func (c Criteria) MarshalJSON() ([]byte, error) {
func (c *Criteria) UnmarshalJSON(data []byte) error {
var aux struct {
All unmarshalConjunctionType `json:"all"`
Any unmarshalConjunctionType `json:"any"`
Sort string `json:"sort"`
Order string `json:"order"`
Limit int `json:"limit"`
LimitPercent int `json:"limitPercent"`
Offset int `json:"offset"`
All optionalConjunction `json:"all"`
Any optionalConjunction `json:"any"`
Sort string `json:"sort"`
Order string `json:"order"`
Limit int `json:"limit"`
LimitPercent int `json:"limitPercent"`
Offset int `json:"offset"`
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if len(aux.Any) > 0 {
c.Expression = Any(aux.Any)
} else if len(aux.All) > 0 {
c.Expression = All(aux.All)
// A Criteria has a single top-level group. Reject files that provide both keys
// (even when one is [] or null) rather than silently dropping one of them.
if aux.All.present && aux.Any.present {
return errors.New("invalid criteria json: 'all' and 'any' cannot both be used at the top level; nest one inside the other instead")
}
if len(aux.Any.rules) > 0 {
c.Expression = Any(aux.Any.rules)
} else if len(aux.All.rules) > 0 {
c.Expression = All(aux.All.rules)
} else {
return errors.New("invalid criteria json. missing rules (key 'all' or 'any')")
}

View File

@ -80,6 +80,28 @@ var _ = Describe("Criteria", func() {
})
})
Context("with both top-level 'all' and 'any'", func() {
It("returns an error instead of silently dropping one of the groups", func() {
jsonStr := `{"any":[{"inPlaylist":{"path":"a.nsp"}}],"all":[{"notInPlaylist":{"path":"b.nsp"}}]}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any")))
})
DescribeTable("rejects both keys even when one group is present but empty",
func(jsonStr string) {
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any")))
},
Entry("empty any", `{"any":[],"all":[{"is":{"loved":true}}]}`),
Entry("empty all", `{"all":[],"any":[{"is":{"loved":true}}]}`),
Entry("null any", `{"any":null,"all":[{"is":{"loved":true}}]}`),
)
})
Describe("LimitPercent", func() {
Describe("JSON round-trip", func() {
It("marshals and unmarshals limitPercent", func() {

View File

@ -33,6 +33,20 @@ func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error {
return nil
}
// optionalConjunction is a top-level "all"/"any" value that remembers whether its
// key was present at all, so a Criteria providing both can be rejected. encoding/json
// calls UnmarshalJSON even for a JSON null, so present is set whenever the key appears
// — including as [] or null — while an absent key leaves it false.
type optionalConjunction struct {
present bool
rules unmarshalConjunctionType
}
func (o *optionalConjunction) UnmarshalJSON(data []byte) error {
o.present = true
return json.Unmarshal(data, &o.rules)
}
func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
m := make(map[string]any)
err := json.Unmarshal(rawValue, &m)

View File

@ -82,7 +82,8 @@ type taskQueueServiceImpl struct {
}
// newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database.
func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) {
// The given ctx bounds the service's background work (queue workers, cleanup loop).
func newTaskQueueService(ctx context.Context, pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) {
dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName)
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating plugin data directory: %w", err)
@ -102,7 +103,7 @@ func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int
return nil, fmt.Errorf("creating taskqueue schema: %w", err)
}
ctx, cancel := context.WithCancel(manager.ctx) //nolint:gosec // cancel is stored in struct and called in Close()
ctx, cancel := context.WithCancel(ctx) //nolint:gosec // cancel is stored in struct and called in Close()
s := &taskQueueServiceImpl{
pluginName: pluginName,

View File

@ -42,15 +42,11 @@ var _ = Describe("TaskQueueService", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(tmpDir)
// Create a mock manager with context
managerCtx, cancel := context.WithCancel(ctx)
manager = &Manager{
plugins: make(map[string]*plugin),
ctx: managerCtx,
}
DeferCleanup(cancel)
service, err = newTaskQueueService("test_plugin", manager, 5)
service, err = newTaskQueueService(ctx, "test_plugin", manager, 5)
Expect(err).ToNot(HaveOccurred())
})
@ -730,14 +726,11 @@ var _ = Describe("TaskQueueService", func() {
service.Close()
// Create a new service pointing to the same DB
managerCtx2, cancel2 := context.WithCancel(ctx)
DeferCleanup(cancel2)
manager2 := &Manager{
plugins: make(map[string]*plugin),
ctx: managerCtx2,
}
service, err = newTaskQueueService("test_plugin", manager2, 5)
service, err = newTaskQueueService(ctx, "test_plugin", manager2, 5)
Expect(err).ToNot(HaveOccurred())
// Override callback to succeed
@ -775,14 +768,11 @@ var _ = Describe("TaskQueueService", func() {
Describe("Plugin isolation", func() {
It("uses separate databases for different plugins", func() {
managerCtx2, cancel2 := context.WithCancel(ctx)
DeferCleanup(cancel2)
manager2 := &Manager{
plugins: make(map[string]*plugin),
ctx: managerCtx2,
}
service2, err := newTaskQueueService("other_plugin", manager2, 5)
service2, err := newTaskQueueService(ctx, "other_plugin", manager2, 5)
Expect(err).ToNot(HaveOccurred())
defer service2.Close()

View File

@ -54,6 +54,7 @@ type wsConnection struct {
// webSocketServiceImpl implements host.WebSocketService.
// It provides plugins with WebSocket communication capabilities.
type webSocketServiceImpl struct {
baseCtx context.Context // bounds the read loops, which outlive the Connect() call
pluginName string
manager *Manager
requiredHosts []string
@ -63,8 +64,9 @@ type webSocketServiceImpl struct {
}
// newWebSocketService creates a new WebSocketService for a plugin.
func newWebSocketService(pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl {
func newWebSocketService(ctx context.Context, pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl {
return &webSocketServiceImpl{
baseCtx: ctx,
pluginName: pluginName,
manager: manager,
requiredHosts: permission.RequiredHosts,
@ -129,11 +131,12 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade
s.connections[connectionID] = wsConn
s.mu.Unlock()
// Start read goroutine with manager's context.
// We use manager.ctx instead of the caller's ctx because the readLoop must
// outlive the Connect() call. The manager's context is cancelled during
// application shutdown, ensuring graceful cleanup.
go s.readLoop(s.manager.ctx, connectionID, wsConn)
// Start read goroutine with the service's base context instead of the
// caller's ctx, because the readLoop must outlive the Connect() call.
// Connections are closed by Close() when the plugin is unloaded, which ends
// the readLoop; the base context is a backstop that also ends it on server
// shutdown (it is never cancelled in one-shot CLI runs).
go s.readLoop(s.baseCtx, connectionID, wsConn)
log.Debug(ctx, "WebSocket connected", "plugin", s.pluginName, "connectionID", connectionID, "url", urlStr)
return connectionID, nil

View File

@ -30,11 +30,23 @@ type serviceContext struct {
allLibraries bool // If true, plugin can access all libraries
}
// baseCtx returns the manager's lifecycle context, for host services that
// outlive the plugin call that created them. It falls back to
// context.Background() when the manager was never started, which is the case
// for CLI commands (e.g. `navidrome plugin enable`) that load plugins without
// calling Start.
func (c *serviceContext) baseCtx() context.Context {
if c.manager.ctx == nil {
return context.Background()
}
return c.manager.ctx
}
// hostServiceEntry defines a host service for table-driven registration.
type hostServiceEntry struct {
name string
hasPermission func(*Permissions) bool
create func(*serviceContext) ([]extism.HostFunction, io.Closer)
create func(*serviceContext) ([]extism.HostFunction, io.Closer, error)
}
// hostServices defines all available host services.
@ -43,119 +55,117 @@ var hostServices = []hostServiceEntry{
{
name: "Config",
hasPermission: func(p *Permissions) bool { return true }, // Always available, no permission required
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newConfigService(ctx.pluginName, ctx.config)
return host.RegisterConfigHostFunctions(service), nil
return host.RegisterConfigHostFunctions(service), nil, nil
},
},
{
name: "SubsonicAPI",
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers))
return host.RegisterSubsonicAPIHostFunctions(service), nil
return host.RegisterSubsonicAPIHostFunctions(service), nil, nil
},
},
{
name: "Scheduler",
hasPermission: func(p *Permissions) bool { return p != nil && p.Scheduler != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newSchedulerService(ctx.pluginName, ctx.manager, scheduler.GetInstance())
return host.RegisterSchedulerHostFunctions(service), service
return host.RegisterSchedulerHostFunctions(service), service, nil
},
},
{
name: "WebSocket",
hasPermission: func(p *Permissions) bool { return p != nil && p.Websocket != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
perm := ctx.permissions.Websocket
service := newWebSocketService(ctx.pluginName, ctx.manager, perm)
return host.RegisterWebSocketHostFunctions(service), service
service := newWebSocketService(ctx.baseCtx(), ctx.pluginName, ctx.manager, perm)
return host.RegisterWebSocketHostFunctions(service), service, nil
},
},
{
name: "Artwork",
hasPermission: func(p *Permissions) bool { return p != nil && p.Artwork != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newArtworkService()
return host.RegisterArtworkHostFunctions(service), nil
return host.RegisterArtworkHostFunctions(service), nil, nil
},
},
{
name: "Cache",
hasPermission: func(p *Permissions) bool { return p != nil && p.Cache != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newCacheService(ctx.pluginName)
return host.RegisterCacheHostFunctions(service), service
return host.RegisterCacheHostFunctions(service), service, nil
},
},
{
name: "Library",
hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
perm := ctx.permissions.Library
service := newLibraryService(ctx.manager.ds, perm, ctx.allowedLibraries, ctx.allLibraries)
return host.RegisterLibraryHostFunctions(service), nil
return host.RegisterLibraryHostFunctions(service), nil, nil
},
},
{
name: "KVStore",
hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
perm := ctx.permissions.Kvstore
service, err := newKVStoreService(ctx.manager.ctx, ctx.pluginName, perm)
service, err := newKVStoreService(ctx.baseCtx(), ctx.pluginName, perm)
if err != nil {
log.Error("Failed to create KVStore service", "plugin", ctx.pluginName, err)
return nil, nil
return nil, nil, err
}
return host.RegisterKVStoreHostFunctions(service), service
return host.RegisterKVStoreHostFunctions(service), service, nil
},
},
{
name: "Users",
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
return host.RegisterUsersHostFunctions(service), nil
return host.RegisterUsersHostFunctions(service), nil, nil
},
},
{
name: "Matcher",
hasPermission: func(p *Permissions) bool { return p != nil && p.Matcher != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
hasFilesystemPerm := ctx.permissions.Library != nil && ctx.permissions.Library.Filesystem
service := newMatcherService(
ctx.manager.ds, hasFilesystemPerm,
newUserAccess(ctx.allowedUsers, ctx.allUsers),
newLibraryAccess(ctx.allowedLibraries, ctx.allLibraries),
)
return host.RegisterMatcherHostFunctions(service), nil
return host.RegisterMatcherHostFunctions(service), nil, nil
},
},
{
name: "HTTP",
hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
perm := ctx.permissions.Http
service := newHTTPService(ctx.pluginName, perm)
return host.RegisterHTTPHostFunctions(service), nil
return host.RegisterHTTPHostFunctions(service), nil, nil
},
},
{
name: "Task",
hasPermission: func(p *Permissions) bool { return p != nil && p.Taskqueue != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
perm := ctx.permissions.Taskqueue
maxConcurrency := int32(1)
if perm.MaxConcurrency > 0 {
maxConcurrency = int32(perm.MaxConcurrency)
}
service, err := newTaskQueueService(ctx.pluginName, ctx.manager, maxConcurrency)
service, err := newTaskQueueService(ctx.baseCtx(), ctx.pluginName, ctx.manager, maxConcurrency)
if err != nil {
log.Error("Failed to create Task service", "plugin", ctx.pluginName, err)
return nil, nil
return nil, nil, err
}
return host.RegisterTaskHostFunctions(service), service
return host.RegisterTaskHostFunctions(service), service, nil
},
},
}
@ -256,6 +266,7 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
// loadPluginWithConfig loads a plugin with configuration from DB.
// The p.Path should point to an .ndp package file.
func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
// NewContext falls back to context.Background() when m.ctx is nil (unstarted manager)
ctx := log.NewContext(m.ctx, "plugin", p.ID)
if m.stopped.Load() {
@ -328,6 +339,15 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
// Build host functions based on permissions from manifest
var hostFunctions []extism.HostFunction
var closers []io.Closer
loaded := false
// On success the closers are owned by the registered plugin; on any
// failure past this point, close them so partially-created services
// don't leak goroutines or file handles.
defer func() {
if !loaded {
closeAll(closers)
}
}()
svcCtx := &serviceContext{
pluginName: p.ID,
@ -341,7 +361,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
}
for _, entry := range hostServices {
if entry.hasPermission(pkg.Manifest.Permissions) {
funcs, closer := entry.create(svcCtx)
funcs, closer, err := entry.create(svcCtx)
if err != nil {
return fmt.Errorf("creating %s service: %w", entry.name, err)
}
hostFunctions = append(hostFunctions, funcs...)
if closer != nil {
closers = append(closers, closer)
@ -400,6 +423,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
}
m.mu.Unlock()
loaded = true
// Call plugin init function
callPluginInit(ctx, m.plugins[p.ID])
@ -407,6 +431,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return nil
}
// closeAll closes host service closers accumulated before a load failure,
// so partially-created services don't leak goroutines or file handles.
func closeAll(closers []io.Closer) {
for _, c := range closers {
_ = c.Close()
}
}
// parsePluginConfig parses a JSON config string into a map of string values.
// For Extism, all config values must be strings, so non-string values are serialized as JSON.
func parsePluginConfig(configJSON string) (map[string]string, error) {

View File

@ -0,0 +1,78 @@
//go:build !windows
package plugins
import (
"crypto/sha256"
"encoding/hex"
"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"
)
var _ = Describe("loadPluginWithConfig", func() {
var manager *Manager
var dataDir string
BeforeEach(func() {
pluginsDir := GinkgoT().TempDir()
dataDir = GinkgoT().TempDir()
src := filepath.Join(testdataDir, "test-taskqueue"+PackageExtension)
data, err := os.ReadFile(src)
Expect(err).ToNot(HaveOccurred())
dest := filepath.Join(pluginsDir, "test-taskqueue"+PackageExtension)
Expect(os.WriteFile(dest, data, 0600)).To(Succeed())
hash := sha256.Sum256(data)
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(pluginsDir)
conf.Server.Plugins.AutoReload = false
conf.Server.DataFolder = conf.NewDir(dataDir)
repo := tests.CreateMockPluginRepo()
repo.Permitted = true
repo.SetData(model.Plugins{{
ID: "test-taskqueue",
Path: dest,
SHA256: hex.EncodeToString(hash[:]),
Enabled: false,
}})
manager = &Manager{
plugins: make(map[string]*plugin),
ds: &tests.MockDataStore{MockedPlugin: repo},
metrics: noopMetricsRecorder{},
subsonicRouter: http.NotFoundHandler(),
}
})
Describe("host service creation failures", func() {
It("reports the Task service creation error instead of a missing host function", func() {
Expect(manager.Start(GinkgoT().Context())).To(Succeed())
DeferCleanup(func() { _ = manager.Stop() })
// Block the taskqueue data dir by creating a file where the directory should be
Expect(os.WriteFile(filepath.Join(dataDir, "plugins"), nil, 0600)).To(Succeed())
err := manager.EnablePlugin(GinkgoT().Context(), "test-taskqueue")
Expect(err).To(MatchError(ContainSubstring("creating Task service")))
Expect(err).ToNot(MatchError(ContainSubstring("not exported")))
})
})
Describe("unstarted manager", func() {
It("enables a taskqueue plugin on a manager that was never started", func() {
// CLI commands (navidrome plugin enable) use the manager without calling Start
Expect(manager.EnablePlugin(GinkgoT().Context(), "test-taskqueue")).To(Succeed())
DeferCleanup(func() { _ = manager.unloadPlugin("test-taskqueue") })
})
})
})

View File

@ -6,6 +6,7 @@ import (
"errors"
"path/filepath"
"testing/fstest"
"time"
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
@ -212,6 +213,15 @@ var _ = Describe("Scanner", Ordered, func() {
_, err := db.Db().ExecContext(ctx, "UPDATE artist SET search_normalized = '' WHERE name = 'GØGGS'")
Expect(err).ToNot(HaveOccurred())
// Backdate the folder so the next full scan reliably sees it as outdated.
// isOutdated() compares folder.updated_at (written by this scan) against the
// next scan's last_scan_started_at with a strict Before(); back-to-back scans
// can capture both within one clock tick on Windows (coarse wall-clock), making
// the refresh flaky. Backdating forces the comparison to be unambiguous.
_, err = db.Db().ExecContext(ctx,
"UPDATE folder SET updated_at = ?", time.Now().Add(-time.Hour))
Expect(err).ToNot(HaveOccurred())
Expect(runScanner(ctx, true)).To(Succeed())
Expect(searchNormalized()).To(Equal("GOGGS"))
})

View File

@ -107,6 +107,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
addShareData(r, data, shareInfo)
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
err = t.Execute(w, data)
if err != nil {
log.Error(r, "Could not execute `index.html` template", err)