🏡 index : quine-global/quintodrome.git

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, 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)

	// 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()

	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(ctx, 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(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(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:
		// File removed - unload if enabled, delete from DB
		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)
		}
	}
}