navidrome/persistence/plugin_repository.go
Deluan Quintão 336fa482f8
fix(plugins): skip the startup error reset when no plugin has an error (#6223)
The plugin manager clears last_error on every startup with an UPDATE that takes the SQLite write lock even when no row matches. On slow storage the startup scan often holds the lock at that moment, so the reset waited out the busy timeout and logged "database is locked", even with no plugins installed. ClearErrors now checks for errors with a read first and only writes when there is something to clear.
2026-09-25 12:19:33 -04:00

153 lines
4.3 KiB
Go

package persistence
import (
"context"
"errors"
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
type pluginRepository struct {
sqlRepository
}
func NewPluginRepository(ctx context.Context, db dbx.Builder) model.PluginRepository {
r := &pluginRepository{}
r.ctx = ctx
r.db = db
r.registerModel(&model.Plugin{}, map[string]filterFunc{
"id": idFilter("plugin"),
"enabled": booleanFilter,
})
return r
}
func (r *pluginRepository) isPermitted() bool {
user := loggedUser(r.ctx)
return user.IsAdmin
}
func (r *pluginRepository) ClearErrors() error {
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
// An UPDATE takes the write lock even when nothing matches, so only run it when there is an error to clear
var hasErrors bool
if err := r.db.NewQuery("SELECT EXISTS (SELECT 1 FROM plugin WHERE last_error != '')").Row(&hasErrors); err != nil {
return err
}
if !hasErrors {
return nil
}
_, err := r.db.NewQuery("UPDATE plugin SET last_error = '' WHERE last_error != ''").Execute()
return err
}
func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) {
if !r.isPermitted() {
return 0, rest.ErrPermissionDenied
}
sql := r.newSelect()
return r.count(sql, options...)
}
func (r *pluginRepository) Delete(id string) error {
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
return r.delete(Eq{"id": id})
}
func (r *pluginRepository) Get(id string) (*model.Plugin, error) {
if !r.isPermitted() {
return nil, rest.ErrPermissionDenied
}
sel := r.newSelect().Where(Eq{"id": id}).Columns("*")
res := model.Plugin{}
err := r.queryOne(sel, &res)
return &res, err
}
func (r *pluginRepository) GetAll(options ...model.QueryOptions) (model.Plugins, error) {
if !r.isPermitted() {
return nil, rest.ErrPermissionDenied
}
sel := r.newSelect(options...).Columns("*")
res := model.Plugins{}
err := r.queryAll(sel, &res)
return res, err
}
func (r *pluginRepository) Put(plugin *model.Plugin) error {
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
plugin.UpdatedAt = time.Now()
if plugin.ID == "" {
return errors.New("plugin ID cannot be empty")
}
// Upsert using INSERT ... ON CONFLICT for atomic operation
_, err := r.db.NewQuery(`
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, allow_write_access, enabled, last_error, sha256, created_at, updated_at)
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:allow_write_access}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
ON CONFLICT(id) DO UPDATE SET
path = excluded.path,
manifest = excluded.manifest,
config = excluded.config,
users = excluded.users,
all_users = excluded.all_users,
libraries = excluded.libraries,
all_libraries = excluded.all_libraries,
allow_write_access = excluded.allow_write_access,
enabled = excluded.enabled,
last_error = excluded.last_error,
sha256 = excluded.sha256,
updated_at = excluded.updated_at
`).Bind(dbx.Params{
"id": plugin.ID,
"path": plugin.Path,
"manifest": plugin.Manifest,
"config": plugin.Config,
"users": plugin.Users,
"all_users": plugin.AllUsers,
"libraries": plugin.Libraries,
"all_libraries": plugin.AllLibraries,
"allow_write_access": plugin.AllowWriteAccess,
"enabled": plugin.Enabled,
"last_error": plugin.LastError,
"sha256": plugin.SHA256,
"created_at": time.Now(),
"updated_at": plugin.UpdatedAt,
}).Execute()
return err
}
func (r *pluginRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *pluginRepository) EntityName() string {
return "plugin"
}
func (r *pluginRepository) NewInstance() any {
return &model.Plugin{}
}
func (r *pluginRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
var _ model.PluginRepository = (*pluginRepository)(nil)
var _ rest.Repository = (*pluginRepository)(nil)