mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-02 07:01:36 +00:00
* feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan <deluan@navidrome.org>
218 lines
6.4 KiB
Go
218 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)
|
|
}
|
|
}
|
|
}
|