🏡 index : quine-global/quintodrome.git

package plugins

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/server/events"
)

// PluginMetadata holds the extracted information from a plugin file
// without fully initializing the plugin.
type PluginMetadata struct {
	Manifest *Manifest
	SHA256   string
}

// adminContext returns a context with admin privileges for DB operations.
func adminContext(ctx context.Context) context.Context {
	return request.WithUser(ctx, model.User{IsAdmin: true})
}

// marshalManifest marshals a manifest to JSON string, returning empty string on error.
func marshalManifest(m *Manifest) string {
	b, _ := json.Marshal(m)
	return string(b)
}

// computeFileSHA256 computes the SHA-256 hash of a file without loading it into memory.
// This is used for quick change detection before full plugin compilation.
func computeFileSHA256(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// addPluginToDB adds a new plugin to the database as disabled.
func (m *Manager) addPluginToDB(ctx context.Context, repo model.PluginRepository, name, path string, metadata *PluginMetadata) error {
	now := time.Now()
	newPlugin := &model.Plugin{
		ID:        name,
		Path:      path,
		Manifest:  marshalManifest(metadata.Manifest),
		SHA256:    metadata.SHA256,
		Enabled:   false,
		CreatedAt: now,
		UpdatedAt: now,
	}
	if err := repo.Put(newPlugin); err != nil {
		return fmt.Errorf("adding plugin to DB: %w", err)
	}
	log.Info(ctx, "Discovered new plugin", "plugin", name)
	m.sendPluginRefreshEvent(ctx, events.Any)
	return nil
}

// updatePluginInDB updates an existing plugin in the database after a file change.
// If the plugin was enabled, it will be unloaded and disabled.
func (m *Manager) updatePluginInDB(ctx context.Context, repo model.PluginRepository, dbPlugin *model.Plugin, path string, metadata *PluginMetadata) error {
	wasEnabled := dbPlugin.Enabled
	if wasEnabled {
		if err := m.unloadPlugin(dbPlugin.ID); err != nil {
			log.Debug(ctx, "Plugin not loaded during change", "plugin", dbPlugin.ID, err)
		}
	}
	dbPlugin.Path = path
	dbPlugin.Manifest = marshalManifest(metadata.Manifest)
	dbPlugin.SHA256 = metadata.SHA256
	dbPlugin.Enabled = false
	dbPlugin.LastError = ""
	dbPlugin.UpdatedAt = time.Now()
	if err := repo.Put(dbPlugin); err != nil {
		return fmt.Errorf("updating plugin in DB: %w", err)
	}
	log.Info(ctx, "Plugin file changed", "plugin", dbPlugin.ID, "wasEnabled", wasEnabled)
	m.sendPluginRefreshEvent(ctx, dbPlugin.ID)
	return nil
}

// removePluginFromDB removes a plugin from the database.
// If the plugin was enabled, it will be unloaded first.
func (m *Manager) removePluginFromDB(ctx context.Context, repo model.PluginRepository, dbPlugin *model.Plugin) error {
	pluginID := dbPlugin.ID
	if dbPlugin.Enabled {
		if err := m.unloadPlugin(pluginID); err != nil {
			log.Debug(ctx, "Plugin not loaded during removal", "plugin", pluginID, err)
		}
	}
	if err := repo.Delete(pluginID); err != nil {
		return fmt.Errorf("deleting plugin from DB: %w", err)
	}
	log.Info(ctx, "Plugin removed", "plugin", pluginID)
	m.sendPluginRefreshEvent(ctx, events.Any)
	return nil
}

// syncPlugins scans the plugins folder and synchronizes with the database.
// It handles new, changed, and removed plugins by comparing SHA-256 hashes.
// - New plugins are added to DB as disabled
// - Changed plugins are updated in DB and disabled if they were enabled
// - Removed plugins are deleted from DB (after unloading if enabled)
func (m *Manager) syncPlugins(ctx context.Context, folder string) error {
	if m.ds == nil {
		return fmt.Errorf("datastore not configured")
	}

	adminCtx := adminContext(ctx)

	// Read current plugins from folder
	entries, err := os.ReadDir(folder)
	if err != nil {
		if os.IsNotExist(err) {
			log.Debug(ctx, "Plugins folder does not exist", "folder", folder)
			return nil
		}
		return fmt.Errorf("reading plugins folder: %w", err)
	}

	// Build map of files in folder
	filesOnDisk := make(map[string]string) // name -> path
	for _, entry := range entries {
		if entry.IsDir() || !strings.HasSuffix(entry.Name(), PackageExtension) {
			log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir())
			continue
		}
		name := strings.TrimSuffix(entry.Name(), PackageExtension)
		filesOnDisk[name] = filepath.Join(folder, entry.Name())
	}
	log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk))

	// Get all plugins from DB
	repo := m.ds.Plugin(adminCtx)
	dbPlugins, err := repo.GetAll()
	if err != nil {
		return fmt.Errorf("reading plugins from DB: %w", err)
	}
	pluginsInDB := make(map[string]*model.Plugin)
	for i := range dbPlugins {
		pluginsInDB[dbPlugins[i].ID] = &dbPlugins[i]
	}
	log.Debug(ctx, "Plugin sync: current DB state", "pluginsInDB", len(pluginsInDB))

	now := time.Now()

	// Process files on disk
	for name, path := range filesOnDisk {
		dbPlugin, exists := pluginsInDB[name]

		// Compute SHA256 first (lightweight operation) to check if plugin changed
		sha256Hash, err := computeFileSHA256(path)
		if err != nil {
			log.Error(ctx, "Failed to compute SHA256 for plugin", "plugin", name, "path", path, err)
			continue
		}

		// If plugin exists in DB with same hash, skip full manifest extraction
		if exists && dbPlugin.SHA256 == sha256Hash {
			// Plugin unchanged - just update path in case folder moved
			if dbPlugin.Path != path {
				dbPlugin.Path = path
				dbPlugin.UpdatedAt = now
				if err := repo.Put(dbPlugin); err != nil {
					log.Error(ctx, "Failed to update plugin path in DB", "plugin", name, err)
				}
			}
			delete(pluginsInDB, name)
			continue
		}

		// Plugin is new or changed - need full manifest extraction
		metadata, err := m.extractManifest(path)
		if err != nil {
			log.Error(ctx, "Failed to extract manifest from plugin", "plugin", name, "path", path, err)
			// Store error in DB if plugin exists
			if exists {
				dbPlugin.LastError = err.Error()
				dbPlugin.UpdatedAt = now
				if dbPlugin.Enabled {
					// Unload broken plugin
					if unloadErr := m.unloadPlugin(name); unloadErr != nil {
						log.Debug(ctx, "Plugin not loaded", "plugin", name)
					}
					dbPlugin.Enabled = false
				}
				if putErr := repo.Put(dbPlugin); putErr != nil {
					log.Error(ctx, "Failed to update plugin in DB", "plugin", name, err)
				}
			}
			delete(pluginsInDB, name)
			continue
		}

		if !exists {
			// New plugin - add to DB as disabled
			if err := m.addPluginToDB(ctx, repo, name, path, metadata); err != nil {
				log.Error(ctx, "Failed to add plugin to DB", "plugin", name, err)
			}
		} else {
			// Plugin changed - update DB
			if err := m.updatePluginInDB(ctx, repo, dbPlugin, path, metadata); err != nil {
				log.Error(ctx, "Failed to update plugin in DB", "plugin", name, err)
			}
		}
		// Mark as processed
		delete(pluginsInDB, name)
	}

	// Remove plugins no longer on disk
	for _, dbPlugin := range pluginsInDB {
		if err := m.removePluginFromDB(ctx, repo, dbPlugin); err != nil {
			log.Error(ctx, "Failed to delete plugin from DB", "plugin", dbPlugin.ID, err)
		}
	}

	return nil
}