mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat: add manual plugin rescan functionality and corresponding UI action
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
64baa16129
commit
8722c7809d
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user