mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-05 21:38:27 -05:00
232 lines
7.0 KiB
Go
232 lines
7.0 KiB
Go
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) {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(entry.Name(), PackageExtension)
|
|
filesOnDisk[name] = filepath.Join(folder, entry.Name())
|
|
}
|
|
|
|
// 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]
|
|
}
|
|
|
|
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
|
|
}
|