feat: add manual plugin rescan functionality and corresponding UI action

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-01-05 19:49:11 -05:00
parent 64baa16129
commit 8722c7809d
9 changed files with 195 additions and 11 deletions

View File

@ -410,6 +410,18 @@ func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON s
})
}
// RescanPlugins triggers a manual rescan of the plugins folder.
// This synchronizes the database with the filesystem, discovering new plugins,
// updating changed ones, and removing deleted ones.
func (m *Manager) RescanPlugins(ctx context.Context) error {
folder := conf.Server.Plugins.Folder
if folder == "" {
return fmt.Errorf("plugins folder not configured")
}
log.Info(ctx, "Manual plugin rescan requested", "folder", folder)
return m.syncPlugins(ctx, folder)
}
// updatePluginSettings is a common implementation for updating plugin settings.
// The updateFn is called to apply the specific field updates to the plugin.
// If the plugin is enabled, it will be reloaded. If users permission is required

View File

@ -373,7 +373,8 @@
"disabledDueToError": "Corrija o erro antes de habilitar",
"disabledUsersRequired": "Selecione usuários antes de habilitar",
"disabledLibrariesRequired": "Selecione bibliotecas antes de habilitar",
"addConfig": "Adicionar configuração"
"addConfig": "Adicionar configuração",
"rescan": "Rescanear"
},
"notifications": {
"enabled": "Plugin habilitado",

View File

@ -28,6 +28,7 @@ type PluginManager interface {
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error
RescanPlugins(ctx context.Context) error
}
type Router struct {

View File

@ -22,6 +22,7 @@ func (api *Router) addPluginRoute(r chi.Router) {
r.Route("/plugin", func(r chi.Router) {
r.Use(pluginsEnabledMiddleware)
r.Get("/", rest.GetAll(constructor))
r.Post("/rescan", api.rescanPlugins)
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(constructor))
@ -30,6 +31,18 @@ func (api *Router) addPluginRoute(r chi.Router) {
})
}
func (api *Router) rescanPlugins(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := api.pluginManager.RescanPlugins(ctx); err != nil {
log.Error(ctx, "Error rescanning plugins", err)
http.Error(w, "Error rescanning plugins: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// Middleware to check if plugins feature is enabled
func pluginsEnabledMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
@ -393,6 +394,32 @@ var _ = Describe("Plugin API", func() {
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})
Describe("POST /api/plugin/rescan", func() {
It("triggers plugin rescan", func() {
req := httptest.NewRequest("POST", "/plugin/rescan", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mockManager.RescanPluginsCalls).To(Equal(1))
})
It("returns error when rescan fails", func() {
mockManager.RescanError = errors.New("folder not configured")
req := httptest.NewRequest("POST", "/plugin/rescan", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
Expect(w.Body.String()).To(ContainSubstring("folder not configured"))
})
})
})
Describe("as regular user", func() {
@ -435,6 +462,16 @@ var _ = Describe("Plugin API", func() {
Expect(w.Code).To(Equal(http.StatusForbidden))
})
It("denies access to POST /api/plugin/rescan", func() {
req := httptest.NewRequest("POST", "/plugin/rescan", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
})
Describe("without authentication", func() {

View File

@ -5,7 +5,7 @@ import (
)
// MockPluginManager is a mock implementation of plugins.PluginManager for testing.
// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, UpdatePluginUsers and UpdatePluginLibraries methods.
// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, UpdatePluginUsers, UpdatePluginLibraries and RescanPlugins methods.
type MockPluginManager struct {
// EnablePluginFn is called when EnablePlugin is invoked. If nil, returns EnableError.
EnablePluginFn func(ctx context.Context, id string) error
@ -17,6 +17,8 @@ type MockPluginManager struct {
UpdatePluginUsersFn func(ctx context.Context, id, usersJSON string, allUsers bool) error
// UpdatePluginLibrariesFn is called when UpdatePluginLibraries is invoked. If nil, returns LibrariesError.
UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries bool) error
// RescanPluginsFn is called when RescanPlugins is invoked. If nil, returns RescanError.
RescanPluginsFn func(ctx context.Context) error
// Default errors to return when Fn callbacks are not set
EnableError error
@ -24,6 +26,7 @@ type MockPluginManager struct {
ConfigError error
UsersError error
LibrariesError error
RescanError error
// Track calls for assertions
EnablePluginCalls []string
@ -42,6 +45,7 @@ type MockPluginManager struct {
LibrariesJSON string
AllLibraries bool
}
RescanPluginsCalls int
}
func (m *MockPluginManager) EnablePlugin(ctx context.Context, id string) error {
@ -94,3 +98,11 @@ func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, libra
}
return m.LibrariesError
}
func (m *MockPluginManager) RescanPlugins(ctx context.Context) error {
m.RescanPluginsCalls++
if m.RescanPluginsFn != nil {
return m.RescanPluginsFn(ctx)
}
return m.RescanError
}

View File

@ -373,7 +373,8 @@
"disabledDueToError": "Fix the error before enabling",
"disabledUsersRequired": "Select users before enabling",
"disabledLibrariesRequired": "Select libraries before enabling",
"addConfig": "Add Configuration"
"addConfig": "Add Configuration",
"rescan": "Rescan"
},
"notifications": {
"enabled": "Plugin enabled",

View File

@ -1,14 +1,19 @@
import React, { useMemo } from 'react'
import React, { useMemo, useState, useCallback } from 'react'
import {
Button,
Datagrid,
TextField,
TopToolbar,
useNotify,
useRecordContext,
useRefresh,
useTranslate,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core'
import { MdError } from 'react-icons/md'
import { MdError, MdRefresh } from 'react-icons/md'
import { List, DateField, SimpleList, useResourceRefresh } from '../common'
import { httpClient } from '../dataProvider'
import ToggleEnabledSwitch from './ToggleEnabledSwitch'
const useStyles = makeStyles((theme) => ({
@ -67,13 +72,53 @@ const ManifestField = ({ source }) => {
return <Typography variant="body2">{manifest[source] || '-'}</Typography>
}
const PluginListActions = () => {
const translate = useTranslate()
const notify = useNotify()
const refresh = useRefresh()
const [loading, setLoading] = useState(false)
const handleRescan = useCallback(() => {
setLoading(true)
httpClient('/api/plugin/rescan', { method: 'POST' })
.then(() => {
refresh()
})
.catch((error) => {
notify(error.message || 'ra.page.error', { type: 'warning' })
})
.finally(() => {
setLoading(false)
})
}, [notify, refresh])
return (
<TopToolbar>
<Button
onClick={handleRescan}
disabled={loading}
label={translate('resources.plugin.actions.rescan')}
data-testid="rescan-button"
>
<MdRefresh />
</Button>
</TopToolbar>
)
}
const PluginList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const translate = useTranslate()
useResourceRefresh('plugin')
return (
<List {...props} sort={{ field: 'id', order: 'ASC' }} exporter={false} bulkActionButtons={false}>
<List
{...props}
sort={{ field: 'id', order: 'ASC' }}
exporter={false}
bulkActionButtons={false}
actions={<PluginListActions />}
>
{isXsmall ? (
<SimpleList
primaryText={(record) => record.id}

View File

@ -1,15 +1,18 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockNotify = vi.fn()
const mockRefresh = vi.fn()
// Mock react-admin hooks
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
useUpdate: vi.fn(() => [vi.fn(), { loading: false }]),
useNotify: vi.fn(() => vi.fn()),
useRefresh: vi.fn(() => vi.fn()),
useNotify: vi.fn(() => mockNotify),
useRefresh: vi.fn(() => mockRefresh),
useTranslate: vi.fn(() => (key) => key),
useResourceContext: vi.fn(() => 'plugin'),
useRecordContext: vi.fn(() => ({
@ -22,6 +25,15 @@ vi.mock('react-admin', async () => {
enabled: true,
lastError: null,
})),
Button: ({ onClick, disabled, label, children }) => (
<button onClick={onClick} disabled={disabled} data-testid="rescan-button">
{children}
{label}
</button>
),
TopToolbar: ({ children }) => (
<div data-testid="top-toolbar">{children}</div>
),
Datagrid: ({ children }) => (
<table data-testid="datagrid">{children}</table>
),
@ -32,8 +44,9 @@ vi.mock('react-admin', async () => {
// Mock common components
vi.mock('../common', async () => {
return {
List: ({ children, ...props }) => (
<div data-testid="list" {...props}>
List: ({ children, actions, ...props }) => (
<div data-testid="list">
{actions}
{children}
</div>
),
@ -59,11 +72,18 @@ vi.mock('./ToggleEnabledSwitch', () => ({
default: () => <span data-testid="toggle-switch" />,
}))
// Mock httpClient
const mockHttpClient = vi.fn()
vi.mock('../dataProvider', () => ({
httpClient: (...args) => mockHttpClient(...args),
}))
import PluginList from './PluginList'
describe('PluginList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHttpClient.mockResolvedValue({})
})
it('renders the list component', () => {
@ -75,4 +95,46 @@ describe('PluginList', () => {
render(<PluginList />)
expect(screen.getByTestId('datagrid')).toBeInTheDocument()
})
it('renders the rescan button', () => {
render(<PluginList />)
expect(screen.getByTestId('rescan-button')).toBeInTheDocument()
})
it('calls rescan endpoint when rescan button is clicked', async () => {
render(<PluginList />)
const rescanButton = screen.getByTestId('rescan-button')
fireEvent.click(rescanButton)
await waitFor(() => {
expect(mockHttpClient).toHaveBeenCalledWith('/api/plugin/rescan', {
method: 'POST',
})
})
})
it('calls refresh after successful rescan', async () => {
render(<PluginList />)
const rescanButton = screen.getByTestId('rescan-button')
fireEvent.click(rescanButton)
await waitFor(() => {
expect(mockRefresh).toHaveBeenCalled()
})
})
it('shows error notification on rescan failure', async () => {
mockHttpClient.mockRejectedValue(new Error('Network error'))
render(<PluginList />)
const rescanButton = screen.getByTestId('rescan-button')
fireEvent.click(rescanButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith('Network error', { type: 'warning' })
})
})
})