navidrome/plugins/manager_readonly_test.go
Deluan Quintão 5b87a60b5d
fix(plugins): load plugin agents in CLI commands (#5959)
* feat(plugins): load plugin agents in CLI commands

A CLI that goes through core/agents saw only built-in agents: getEnabledAgentNames asks
Manager.PluginNames, which reads a map populated solely by Manager.Start, and only the
server calls that. On a plugin-using install the CLI's agent list was quietly short —
artwork explain --live could name deezer for an artist whose stored source was
external:apple-music, because apple-music was invisible to it.

Adds Manager.LoadPlugins, the read-only counterpart to Start: extism/wazero init plus
loadEnabledPlugins, without the folder sync, the error clearing, the cache purge or the
watcher. It follows what the read-only plugin commands already do — list, info and
validate read the DB and never start the manager — except that capabilities are detected
from the WASM exports, not declared in the manifest, so instantiating is the only accurate
source for what a plugin provides.

loadEnabledPlugins disabled a plugin and recorded LastError when a load failed. That is
right for the server and wrong for a diagnostic, so it is now gated on the read-only flag:
inspecting a plugin must not disable it.

No Subsonic router is required. Start log.Fatals without one, but the host function it
feeds already nil-checks and reports 'SubsonicAPI router not available' at call time, so a
plugin that reaches for it gets an error instead of the process dying. That Fatal's message
also claimed the DataStore was missing; it checks the router.

* fix(cli): correct the reprocess estimate's plugin caveat

imageAgentCount now receives a manager with plugins loaded, so the external estimate
already includes plugin image agents — but the disclaimer still said they were not
counted, which told operators the opposite of what the number meant.

Replaced rather than dropped: loadPluginAgents warns and continues when LoadPlugins
fails, and LoadPlugins is a no-op when plugins are disabled or no folder is set, so
there are still runs where plugin agents genuinely are not counted. The wording now
covers all three cases, and the stale comment above it said the CLI never starts the
plugin manager, which is what this branch changed.

Found by Codex on 648cf38e9.

* fix(plugins): load only the configured agents, and gate plugin init

Loading a plugin is not free: the service constructors create a KVStore or TaskQueue
database and a Storage directory for any plugin whose manifest declares those
permissions, and the plugin's own init then runs arbitrary code. loadEnabledPlugins
loads every enabled plugin, so inspecting artwork was starting scrobblers, schedulers
and lyrics plugins that could never supply an image.

Measured on a copy of a production library: 'artwork explain' created
apple-music/kvstore.db, nd-lyrics/kvstore.db and listenbrainz-daily-playlist/taskqueue.db.
The last one matters most — CreateQueue resets rows with status='running' to 'pending',
which against a live server sets up its in-flight tasks to run twice.

LoadPlugins now takes the names to load, and the artwork CLI passes the Agents list: a
plugin that is not a configured agent can never win, so there is nothing to gain by
instantiating it. The same two runs now create only apple-music, which is a configured
agent and therefore the cost of answering the question.

Init is gated separately on the caller's intent rather than on read-only. 'explain --live'
already means 'reach the provider', so it runs init; plain 'explain' and 'reprocess'
promise no external requests and must not. Documented in the --live flag help.

Found by Codex on 28eb39ac4.

* perf(cli): load plugin agents only when the selection can consult one

Explaining disc or media file artwork loaded every configured metadata plugin, though
neither resolver ever reaches an agent: resolveMediaFile is embedded-only and
discArtworkReader.selectImage refuses external outright. With --live that also ran plugin
init for a walk that provably cannot reach the network. The load now sits inside the
artist/album branch that already exists, so it is a move rather than a new condition.

reprocess did the same for a radio-only selection, whose estimate is unconditionally zero.
It is gated on needsImageAgents, which asks exactly what ExternalLookupsPerItem asks, so
the two cannot disagree. An artist/album test would look equivalent and would silently
zero the playlist estimate, whose generated grid resolves album art through those agents;
a test pins that, and reverting the predicate to a whitelist fails it.

Found by Codex on b78e67b07.

* docs(artwork): trim the comments this branch added to the project budget

Six blocks ran past the one-to-two line limit. The LoadPlugins doc was eleven lines over
three paragraphs, needsImageAgents spent two of its four explaining an alternative that was
rejected, and inspectOpts restated what LoadPlugins already says.

What went is reviewer-facing prose that belongs in a commit message: the enumeration of
what Start does that this skips, and why an artist/album predicate would have been wrong.
What stayed is the reasoning a future reader needs at that line, notably that instantiating
a plugin creates its declared services, and that playlists consume the album agent count.

* docs(artwork): correct the breaker comment after the recovery ramp

It still said a success re-closes the breaker, which stopped being true when closing
started requiring breakerRecoveries consecutive answers.

* refactor(plugins): rename the scoped-load options to transientLoad

inspect claimed the load was only looking, which is false when runInit is true: it
instantiates the plugin and runs its init, which may open sockets. transient is accurate
for every use of the field, and explains all three behaviours it gates. A load that will
not outlive the command has no business persisting findings, instantiating plugins it will
never consult, or starting background work it is about to tear down.
2026-08-15 11:01:36 -04:00

126 lines
4.0 KiB
Go

//go:build !windows
package plugins
import (
"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("Manager.LoadPlugins", func() {
var (
mgr *Manager
repo *tests.MockPluginRepo
tmpDir string
)
// newManager builds a manager over rows the caller can corrupt, with no Subsonic router: a CLI
// has none, and Start would log.Fatal on that.
newManager := func(rows model.Plugins) *Manager {
DeferCleanup(configtest.SetupConfig())
var err error
tmpDir, err = os.MkdirTemp("", "plugins-readonly-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _ = os.RemoveAll(tmpDir) })
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = conf.NewDir(tmpDir)
if rows == nil {
rows = installTestPlugins(tmpDir, "test-metadata-agent"+PackageExtension)
for i := range rows {
rows[i].AllUsers = true
}
}
repo = tests.CreateMockPluginRepo()
repo.Permitted = true
repo.SetData(rows)
m := &Manager{
plugins: make(map[string]*plugin),
ds: &tests.MockDataStore{MockedPlugin: repo},
metrics: noopMetricsRecorder{},
}
DeferCleanup(func() { _ = m.Stop() })
return m
}
It("detects capabilities without a Subsonic router configured", func() {
mgr = newManager(nil)
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("test-metadata-agent"))
})
Context("when a plugin cannot be loaded", func() {
brokenRows := func() model.Plugins {
return model.Plugins{{
ID: "broken", Path: filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp"),
Enabled: true, AllUsers: true,
}}
}
It("leaves the stored row untouched", func() {
mgr = newManager(brokenRows())
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed())
stored, err := repo.Get("broken")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Enabled).To(BeTrue(), "inspecting a plugin must never disable it")
Expect(stored.LastError).To(BeEmpty())
})
// Without this the test above would pass for the wrong reason. Start cannot be used: it
// syncs the folder first, dropping a row whose file is missing before any load.
It("still disables it when not read-only", func() {
mgr = newManager(brokenRows())
Expect(mgr.loadEnabledPlugins(GinkgoT().Context())).To(Succeed())
stored, err := repo.Get("broken")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Enabled).To(BeFalse())
Expect(stored.LastError).ToNot(BeEmpty())
})
})
// Loading a plugin creates its host services — a KVStore or task queue database on disk — so a
// plugin that could never supply an image must not be instantiated just to be ignored.
It("does not load a plugin that is not in the agent list", func() {
mgr = newManager(nil)
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"some-other-agent"}, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty())
})
It("does nothing when no agents are configured", func() {
mgr = newManager(nil)
Expect(mgr.LoadPlugins(GinkgoT().Context(), nil, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty())
// Not even the wazero cache: with nothing to load there is nothing to compile.
Expect(filepath.Join(tmpDir, "plugins")).ToNot(BeADirectory())
})
It("does nothing when the plugin system is disabled", func() {
mgr = newManager(nil)
conf.Server.Plugins.Enabled = false
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty())
})
})