mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
Some checks are pending
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
* feat(plugins): export ReadPackageManifest and ValidatePackage for off-disk inspection * test(plugins): cover ValidatePackage cross-field validation branch * feat(cmd): add 'plugin list' command * refactor(cmd): drop premature pluginManager interface until first use * feat(cmd): add 'plugin enable' and 'plugin disable' commands * feat(cmd): add 'plugin edit' for config and permission updates * test(cmd): cover plugin edit aborting on config validation failure * feat(cmd): add 'plugin info' and 'plugin validate' (installed id or .ndp file) * test(cmd): unit-test formatManifestInfo nil-guard and json branches * feat(cmd): add 'plugin rescan' command * feat(cmd): enrich plugin info text output and re-validate config on validate * fix(cmd): omit zero-value timestamps in plugin info text output * fix(cmd): give plugin list and info separate format flag vars A shared package-global bound to both commands' --format flag caused the later init() registration (info, default "text") to clobber the earlier one (list, default "table"), so 'plugin list' with no -f failed with 'invalid output format "text"'. Split into pluginListFormat / pluginInfoFormat and add a regression test on the registered defaults. * fix(plugins): address code-review findings on plugin CLI - edit: read-modify-write merge so flipping one permission flag no longer wipes unspecified fields (matches the native API); reject non-JSON --users/--libraries values. - info: return an error on unknown --format (was silently text); include sha256 in off-disk JSON output; align the text columns. - move declaredPermissions onto plugins.Permissions.DeclaredNames() as the single source of truth; drop ValidatePackage's redundant Validate call. - readConfigFile: pass ctx to log.Fatal; copy flag globals before taking their address; trim comments that restated the code. * refactor(plugins): export ReadManifest/ComputeFileSHA256, drop thin CLI wrappers Remove the ReadPackageManifest/ComputeFileSHA256/ValidatePackage wrappers in favor of exporting the underlying readManifest->ReadManifest and computeFileSHA256->ComputeFileSHA256 directly. ValidatePackage was identical to ReadPackageManifest (readManifest already validates via ParseManifest), so the CLI's validate path now calls ReadManifest too. * test(plugins): consolidate ReadManifest specs and move ComputeFileSHA256 tests Merge the two ReadManifest Describe blocks into one, and move the ComputeFileSHA256 tests out of package_test.go into a new manager_sync_test.go (where the function now lives), deduping the overlapping hash specs. * test(plugins): use createTestPackage everywhere, drop writeNDP helper The schema-invalid and cross-field ReadManifest specs now build packages with createTestPackage (typed Manifest) instead of the raw-JSON writeNDP helper, so there is a single package-building helper. Also trims the gratuitous 1MB wasm buffer in the read-only spec to a few bytes. * test(plugins): drop duplicate missing-manifest spec from ReadManifest The openPackage block already covers the missing-manifest.json error path (shared zip-walk code); ReadManifest's identical copy added no distinct coverage. * test(plugins): fix misleading ReadManifest spec name and drop unused wasm bytes The spec was named 'should read only the manifest without loading wasm' but ReadManifest returns only (*Manifest, error) — it cannot assert whether wasm was loaded. Rename to what it actually verifies (manifest parses from a package that also contains a wasm entry) and pass nil wasm bytes, since the contents are never observed. * fix(cmd): address PR review feedback on plugin CLI - edit --users/--libraries now accept both comma-separated and JSON-array input (CSV is converted to the JSON the manager stores); help text documents both. - Permissions.DeclaredNames() derives names by reflecting over the generated struct's json tags instead of a hand-maintained list, so new permission types are picked up automatically. - isPackagePath checks only the .ndp suffix, so a mistyped package path yields a precise 'no such file' error instead of falling back to a misleading 'Plugin not found'. - Restore a ReadManifest test for the missing-manifest.json error path (it has its own branch, separate from openPackage). * docs(cmd): trim verbose comments to the why * fix(cmd): setting an explicit users/libraries list clears the all-* flag Per Codex review: with allUsers/allLibraries true, 'plugin edit --users <list>' preserved the all-* flag so the allow-list was silently ignored, and the mutually-exclusive flags meant it couldn't be corrected in one command. An explicit list now implies all-*=false.
217 lines
6.4 KiB
Go
217 lines
6.4 KiB
Go
package plugins
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/rjeczalik/notify"
|
|
)
|
|
|
|
// debounceDuration is the time to wait before acting on file events
|
|
// to handle multiple rapid events for the same file.
|
|
const debounceDuration = 2 * time.Second
|
|
|
|
// startWatcher starts the file watcher for the plugins folder.
|
|
// It watches for CREATE, WRITE, and REMOVE events on .wasm files.
|
|
func (m *Manager) startWatcher() error {
|
|
folder := conf.Server.Plugins.Folder.String()
|
|
if folder == "" {
|
|
return nil
|
|
}
|
|
|
|
m.watcherEvents = make(chan notify.EventInfo, 10)
|
|
m.watcherDone = make(chan struct{})
|
|
m.debounceTimers = make(map[string]*time.Timer)
|
|
m.debounceMu = sync.Mutex{}
|
|
|
|
// Watch the plugins folder (not recursive)
|
|
// We filter for .wasm files in the event handler
|
|
if err := notify.Watch(folder, m.watcherEvents, notify.Create, notify.Write, notify.Remove, notify.Rename); err != nil {
|
|
close(m.watcherEvents)
|
|
return err
|
|
}
|
|
|
|
log.Info(m.ctx, "Started plugin file watcher", "folder", folder)
|
|
|
|
go m.watcherLoop()
|
|
|
|
return nil
|
|
}
|
|
|
|
// stopWatcher stops the file watcher
|
|
func (m *Manager) stopWatcher() {
|
|
if m.watcherEvents == nil {
|
|
return
|
|
}
|
|
|
|
notify.Stop(m.watcherEvents)
|
|
close(m.watcherDone)
|
|
|
|
// Cancel any pending debounce timers
|
|
m.debounceMu.Lock()
|
|
for _, timer := range m.debounceTimers {
|
|
timer.Stop()
|
|
}
|
|
m.debounceTimers = nil
|
|
m.debounceMu.Unlock()
|
|
|
|
log.Debug(m.ctx, "Stopped plugin file watcher")
|
|
}
|
|
|
|
// watcherLoop processes file watcher events
|
|
func (m *Manager) watcherLoop() {
|
|
for {
|
|
select {
|
|
case event, ok := <-m.watcherEvents:
|
|
if !ok {
|
|
return
|
|
}
|
|
m.handleWatcherEvent(event)
|
|
case <-m.ctx.Done():
|
|
return
|
|
case <-m.watcherDone:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleWatcherEvent processes a single file watcher event with debouncing
|
|
func (m *Manager) handleWatcherEvent(event notify.EventInfo) {
|
|
path := event.Path()
|
|
|
|
// Only process .ndp package files
|
|
if !strings.HasSuffix(path, PackageExtension) {
|
|
return
|
|
}
|
|
|
|
pluginName := strings.TrimSuffix(filepath.Base(path), PackageExtension)
|
|
|
|
log.Trace(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path)
|
|
|
|
// Debounce: cancel any pending timer for this plugin and start a new one
|
|
m.debounceMu.Lock()
|
|
if timer, exists := m.debounceTimers[pluginName]; exists {
|
|
timer.Stop()
|
|
}
|
|
|
|
// Note: We don't capture the event type here. Instead, processPluginEvent
|
|
// checks if the file exists when the timer fires. This handles sequences like
|
|
// Remove+Create+Rename correctly by checking actual file state after debounce.
|
|
m.debounceTimers[pluginName] = time.AfterFunc(debounceDuration, func() {
|
|
m.processPluginEvent(pluginName)
|
|
})
|
|
m.debounceMu.Unlock()
|
|
}
|
|
|
|
// pluginAction represents the action to take on a plugin based on file state
|
|
type pluginAction int
|
|
|
|
const (
|
|
actionNone pluginAction = iota // No action needed
|
|
actionUpdate // File exists: add new or update existing plugin in DB
|
|
actionRemove // File gone: remove plugin from DB (unload if enabled)
|
|
)
|
|
|
|
// determinePluginAction decides what action to take based on file existence.
|
|
// We check file existence rather than relying on event type because:
|
|
// 1. Events can be coalesced on some systems (macOS FSEvents)
|
|
// 2. Rename events can mean either "renamed away" (remove) or "renamed to" (add)
|
|
// 3. Build tools often do atomic writes (write temp file, rename to target)
|
|
// By checking existence, we handle all these cases correctly.
|
|
func determinePluginAction(path string) pluginAction {
|
|
if _, err := os.Stat(path); err == nil {
|
|
// File exists - treat as add/update
|
|
return actionUpdate
|
|
}
|
|
// File doesn't exist - it was removed
|
|
return actionRemove
|
|
}
|
|
|
|
// processPluginEvent handles the actual plugin load/unload/reload after debouncing.
|
|
// - If file exists: extract manifest, add or update plugin in DB
|
|
// - If file gone: unload if enabled, delete from DB
|
|
func (m *Manager) processPluginEvent(pluginName string) {
|
|
// Don't process if manager is stopping/stopped (atomic check to avoid race with Stop())
|
|
if m.stopped.Load() {
|
|
return
|
|
}
|
|
|
|
// Clean up debounce timer entry
|
|
m.debounceMu.Lock()
|
|
delete(m.debounceTimers, pluginName)
|
|
m.debounceMu.Unlock()
|
|
|
|
folder := conf.Server.Plugins.Folder.String()
|
|
ndpPath := filepath.Join(folder, pluginName+PackageExtension)
|
|
|
|
action := determinePluginAction(ndpPath)
|
|
log.Debug(m.ctx, "Plugin event action", "plugin", pluginName, "action", action, "path", ndpPath)
|
|
|
|
ctx := adminContext(m.ctx)
|
|
repo := m.ds.Plugin(ctx)
|
|
|
|
switch action {
|
|
case actionUpdate:
|
|
// File changed - check SHA256 first, then extract manifest if needed
|
|
sha256Hash, err := ComputeFileSHA256(ndpPath)
|
|
if err != nil {
|
|
log.Error(m.ctx, "Failed to compute SHA256 for changed plugin", "plugin", pluginName, err)
|
|
return
|
|
}
|
|
|
|
dbPlugin, err := repo.Get(pluginName)
|
|
if err != nil {
|
|
// Plugin not in DB yet, need full manifest extraction to add it
|
|
metadata, extractErr := m.extractManifest(ndpPath)
|
|
if extractErr != nil {
|
|
log.Error(m.ctx, "Failed to extract manifest from new plugin", "plugin", pluginName, extractErr)
|
|
return
|
|
}
|
|
if addErr := m.addPluginToDB(m.ctx, repo, pluginName, ndpPath, metadata); addErr != nil {
|
|
log.Error(m.ctx, "Failed to add plugin to DB", "plugin", pluginName, addErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check if actually changed using lightweight SHA256 comparison
|
|
if dbPlugin.SHA256 == sha256Hash {
|
|
return // No actual change
|
|
}
|
|
|
|
// Plugin changed - now extract full manifest
|
|
metadata, err := m.extractManifest(ndpPath)
|
|
if err != nil {
|
|
log.Error(m.ctx, "Failed to extract manifest from changed plugin", "plugin", pluginName, err)
|
|
// Update error in DB
|
|
dbPlugin.LastError = err.Error()
|
|
dbPlugin.UpdatedAt = time.Now()
|
|
if dbPlugin.Enabled {
|
|
_ = m.unloadPlugin(pluginName)
|
|
dbPlugin.Enabled = false
|
|
}
|
|
_ = repo.Put(dbPlugin)
|
|
return
|
|
}
|
|
|
|
if err := m.updatePluginInDB(m.ctx, repo, dbPlugin, ndpPath, metadata); err != nil {
|
|
log.Error(m.ctx, "Failed to update plugin in DB", "plugin", pluginName, err)
|
|
}
|
|
|
|
case actionRemove:
|
|
// File removed - unload if enabled, delete from DB
|
|
dbPlugin, err := repo.Get(pluginName)
|
|
if err != nil {
|
|
log.Debug(m.ctx, "Removed plugin not in DB", "plugin", pluginName)
|
|
return
|
|
}
|
|
|
|
if err := m.removePluginFromDB(m.ctx, repo, dbPlugin); err != nil {
|
|
log.Error(m.ctx, "Failed to delete plugin from DB", "plugin", pluginName, err)
|
|
}
|
|
}
|
|
}
|