package plugins
import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/rjeczalik/notify"
)
const debounceDuration = 2 * time.Second
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{}
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
}
func (m *Manager) stopWatcher() {
if m.watcherEvents == nil {
return
}
notify.Stop(m.watcherEvents)
close(m.watcherDone)
m.debounceMu.Lock()
for _, timer := range m.debounceTimers {
timer.Stop()
}
m.debounceTimers = nil
m.debounceMu.Unlock()
log.Debug(m.ctx, "Stopped plugin file watcher")
}
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
}
}
}
func (m *Manager) handleWatcherEvent(event notify.EventInfo) {
path := event.Path()
if !strings.HasSuffix(path, PackageExtension) {
return
}
pluginName, ok := pluginIDFromPath(path)
if !ok {
log.Warn(m.ctx, "Ignoring plugin file with unusable name", "path", path)
return
}
log.Trace(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path)
m.debounceMu.Lock()
if timer, exists := m.debounceTimers[pluginName]; exists {
timer.Stop()
}
m.debounceTimers[pluginName] = time.AfterFunc(debounceDuration, func() {
m.processPluginEvent(pluginName)
})
m.debounceMu.Unlock()
}
type pluginAction int
const (
actionNone pluginAction = iota
actionUpdate
actionRemove
)
func determinePluginAction(path string) pluginAction {
if _, err := os.Stat(path); err == nil {
return actionUpdate
}
return actionRemove
}
func (m *Manager) processPluginEvent(pluginName string) {
if m.stopped.Load() {
return
}
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()
switch action {
case actionUpdate:
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(ctx, pluginName)
if err != nil {
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(ctx, repo, pluginName, ndpPath, metadata); addErr != nil {
log.Error(m.ctx, "Failed to add plugin to DB", "plugin", pluginName, addErr)
}
return
}
if dbPlugin.SHA256 == sha256Hash {
return
}
metadata, err := m.extractManifest(ndpPath)
if err != nil {
log.Error(m.ctx, "Failed to extract manifest from changed plugin", "plugin", pluginName, err)
dbPlugin.LastError = err.Error()
dbPlugin.UpdatedAt = time.Now()
if dbPlugin.Enabled {
_ = m.unloadPlugin(pluginName)
dbPlugin.Enabled = false
}
_ = repo.Put(ctx, dbPlugin)
return
}
if err := m.updatePluginInDB(ctx, repo, dbPlugin, ndpPath, metadata); err != nil {
log.Error(m.ctx, "Failed to update plugin in DB", "plugin", pluginName, err)
}
case actionRemove:
dbPlugin, err := repo.Get(ctx, pluginName)
if err != nil {
log.Debug(m.ctx, "Removed plugin not in DB", "plugin", pluginName)
return
}
if err := m.removePluginFromDB(ctx, repo, dbPlugin); err != nil {
log.Error(m.ctx, "Failed to delete plugin from DB", "plugin", pluginName, err)
}
}
}