navidrome/plugins/manager_sync.go
Deluan Quintão f48943c058
fix(plugins): discard buffered scrobbles when a plugin is removed (#5737)
* fix(plugins): discard buffered scrobbles when a plugin is removed

Scrobbles are buffered in the DB per service, keyed by the plugin name.
When a plugin was removed (deleted from the plugins folder and detected by
the sync), its pending buffer entries were left behind forever: the drain
goroutine is stopped on the next scrobbler refresh, so the rows were never
retried nor discarded. Worse, if a plugin with the same name was installed
later, the stale entries would be drained into it - potentially a completely
unrelated plugin that just reuses the name.

Add a Discard(service) method to ScrobbleBufferRepository and call it from
removePluginFromDB, right after the plugin record is deleted. Disabling a
plugin intentionally keeps its buffered scrobbles, consistent with the
buffer's purpose of surviving temporary outages, and transient unload/reload
cycles during config updates are unaffected since they never delete the
plugin record.

* fix(plugins): don't wipe builtin scrobbler queues on plugin removal

Buffer entries are keyed by service name only, and removePluginFromDB runs
for any removed plugin file, so removing a plugin named e.g. lastfm.ndp -
regardless of its capability - would discard the builtin Last.fm retry
queue. Skip the discard when the plugin name is owned by a registered
builtin scrobbler, exposed via a new scrobbler.IsBuiltinScrobbler helper.
Reported by Codex review on the PR.

Also drop the testBroker usage from the new removePluginFromDB spec: it is
defined in manager_test.go which is excluded on Windows, breaking the
Windows test build. sendPluginRefreshEvent is nil-safe, so no broker is
needed.
2026-07-08 12:37:17 -04:00

245 lines
8 KiB
Go

package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/core/scrobbler"
"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)
}
// Discard any scrobbles still buffered for the removed plugin, so they are
// not delivered to an unrelated plugin that reuses the same name later.
// Skip names owned by builtin scrobblers: buffer entries are keyed by
// service name, so removing a plugin file named e.g. "lastfm.ndp" must not
// wipe the builtin Last.fm retry queue.
if scrobbler.IsBuiltinScrobbler(pluginID) {
log.Debug(ctx, "Keeping buffered scrobbles: name is owned by a builtin scrobbler", "plugin", pluginID)
} else if err := m.ds.ScrobbleBuffer(ctx).Discard(pluginID); err != nil {
log.Error(ctx, "Error discarding buffered scrobbles for removed plugin", "plugin", pluginID, 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
}