mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-26 10:17:09 +01:00
feat(library): implement scanner interface for triggering library scans on create/update
Signed-off-by: Deluan <deluan@navidrome.org> # Conflicts: # cmd/wire_gen.go # cmd/wire_injectors.go # Conflicts: # cmd/wire_gen.go # Conflicts: # cmd/wire_gen.go # cmd/wire_injectors.go
This commit is contained in:
parent
c8ee3edba7
commit
12562dbfab
6 changed files with 186 additions and 29 deletions
|
|
@ -110,7 +110,7 @@ func mainContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
|||
func startServer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
a := CreateServer()
|
||||
a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter())
|
||||
a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter(ctx))
|
||||
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter(ctx))
|
||||
a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter())
|
||||
if conf.Server.LastFM.Enabled {
|
||||
|
|
|
|||
|
|
@ -52,13 +52,23 @@ func CreateServer() *server.Server {
|
|||
return serverServer
|
||||
}
|
||||
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
library := core.NewLibrary(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
library := core.NewLibrary(dataStore, scannerScanner)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library)
|
||||
return router
|
||||
}
|
||||
|
|
@ -186,7 +196,7 @@ func getPluginManager() plugins.Manager {
|
|||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ var allProviders = wire.NewSet(
|
|||
db.Db,
|
||||
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
|
||||
wire.Bind(new(core.Scanner), new(scanner.Scanner)),
|
||||
)
|
||||
|
||||
func CreateDataStore() model.DataStore {
|
||||
|
|
@ -58,7 +59,7 @@ func CreateServer() *server.Server {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
|
|
@ -18,6 +19,11 @@ import (
|
|||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
// Scanner defines the interface for triggering library scans
|
||||
type Scanner interface {
|
||||
ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
|
||||
}
|
||||
|
||||
// Library provides business logic for library management and user-library associations
|
||||
type Library interface {
|
||||
GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error)
|
||||
|
|
@ -28,12 +34,13 @@ type Library interface {
|
|||
}
|
||||
|
||||
type libraryService struct {
|
||||
ds model.DataStore
|
||||
ds model.DataStore
|
||||
scanner Scanner
|
||||
}
|
||||
|
||||
// NewLibrary creates a new Library service
|
||||
func NewLibrary(ds model.DataStore) Library {
|
||||
return &libraryService{ds: ds}
|
||||
func NewLibrary(ds model.DataStore, scanner Scanner) Library {
|
||||
return &libraryService{ds: ds, scanner: scanner}
|
||||
}
|
||||
|
||||
// User-library association operations
|
||||
|
|
@ -110,7 +117,7 @@ func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
|
|||
LibraryRepository: repo,
|
||||
Repository: repo.(rest.Repository),
|
||||
ds: s.ds,
|
||||
service: s,
|
||||
scanner: s.scanner,
|
||||
}
|
||||
return wrapper
|
||||
}
|
||||
|
|
@ -120,7 +127,7 @@ type libraryRepositoryWrapper struct {
|
|||
model.LibraryRepository
|
||||
ctx context.Context
|
||||
ds model.DataStore
|
||||
service *libraryService
|
||||
scanner Scanner
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
|
|
@ -134,6 +141,20 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
|
|||
return "", r.mapError(err)
|
||||
}
|
||||
|
||||
// Trigger scan after successful library creation
|
||||
if r.scanner != nil {
|
||||
go func() {
|
||||
log.Info(r.ctx, "Triggering scan for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
|
||||
start := time.Now()
|
||||
warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error scanning new library", "libraryID", lib.ID, "name", lib.Name, err)
|
||||
} else {
|
||||
log.Info(r.ctx, "Scan completed for new library", "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return strconv.Itoa(lib.ID), nil
|
||||
}
|
||||
|
||||
|
|
@ -149,13 +170,34 @@ func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols ..
|
|||
return err
|
||||
}
|
||||
|
||||
// Verify library exists
|
||||
if _, err := r.Get(libID); err != nil {
|
||||
// Get the original library to check if path changed
|
||||
originalLib, err := r.Get(libID)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
pathChanged := originalLib.Path != lib.Path
|
||||
|
||||
err = r.LibraryRepository.Put(lib)
|
||||
return r.mapError(err)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
// Trigger scan if path was updated
|
||||
if pathChanged && r.scanner != nil {
|
||||
go func() {
|
||||
log.Info(r.ctx, "Triggering scan for updated library", "libraryID", lib.ID, "name", lib.Name, "oldPath", originalLib.Path, "newPath", lib.Path)
|
||||
start := time.Now()
|
||||
warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for path change
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error scanning updated library", "libraryID", lib.ID, "name", lib.Name, err)
|
||||
} else {
|
||||
log.Info(r.ctx, "Scan completed for updated library", "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Delete(id string) error {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,24 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// mockScanner provides a simple mock implementation of core.Scanner for testing
|
||||
type mockScanner struct {
|
||||
ScanCalls []ScanCall
|
||||
}
|
||||
|
||||
type ScanCall struct {
|
||||
FullScan bool
|
||||
Context context.Context
|
||||
}
|
||||
|
||||
func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) {
|
||||
m.ScanCalls = append(m.ScanCalls, ScanCall{
|
||||
FullScan: fullScan,
|
||||
Context: ctx,
|
||||
})
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// These tests require the local storage adapter and the taglib extractor to be registered.
|
||||
var _ = Describe("Library Service", func() {
|
||||
var service core.Library
|
||||
|
|
@ -26,6 +44,7 @@ var _ = Describe("Library Service", func() {
|
|||
var userRepo *tests.MockedUserRepo
|
||||
var ctx context.Context
|
||||
var tempDir string
|
||||
var scanner *mockScanner
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
|
@ -36,7 +55,9 @@ var _ = Describe("Library Service", func() {
|
|||
ds.MockedLibrary = libraryRepo
|
||||
ds.MockedUser = userRepo
|
||||
|
||||
service = core.NewLibrary(ds)
|
||||
// Create a mock scanner that tracks calls
|
||||
scanner = &mockScanner{}
|
||||
service = core.NewLibrary(ds, scanner)
|
||||
ctx = context.Background()
|
||||
|
||||
// Create a temporary directory for testing valid paths
|
||||
|
|
@ -585,4 +606,100 @@ var _ = Describe("Library Service", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scan Triggering", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
r := service.NewRepository(ctx)
|
||||
repo = r.(rest.Persistable)
|
||||
})
|
||||
|
||||
It("triggers scan when creating a new library", func() {
|
||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Wait briefly for the goroutine to complete
|
||||
Eventually(func() int {
|
||||
return len(scanner.ScanCalls)
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
// Verify scan was called with correct parameters
|
||||
Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
|
||||
})
|
||||
|
||||
It("triggers scan when updating library path", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Original Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Create a new temporary directory for the update
|
||||
newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(newTempDir) })
|
||||
|
||||
// Update the library with a new path
|
||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
|
||||
err = repo.Update("1", library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Wait briefly for the goroutine to complete
|
||||
Eventually(func() int {
|
||||
return len(scanner.ScanCalls)
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
// Verify scan was called with correct parameters
|
||||
Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
|
||||
})
|
||||
|
||||
It("does not trigger scan when updating library without path change", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Original Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Update the library name only (same path)
|
||||
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
|
||||
err := repo.Update("1", library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Wait a bit to ensure no scan was triggered
|
||||
Consistently(func() int {
|
||||
return len(scanner.ScanCalls)
|
||||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
|
||||
It("does not trigger scan when library creation fails", func() {
|
||||
// Try to create library with invalid data (empty name)
|
||||
library := &model.Library{Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Ensure no scan was triggered since creation failed
|
||||
Consistently(func() int {
|
||||
return len(scanner.ScanCalls)
|
||||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
|
||||
It("does not trigger scan when library update fails", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Original Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Try to update with invalid data (empty name)
|
||||
library := &model.Library{ID: 1, Name: "", Path: tempDir}
|
||||
err := repo.Update("1", library)
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Ensure no scan was triggered since update failed
|
||||
Consistently(func() int {
|
||||
return len(scanner.ScanCalls)
|
||||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
|
@ -253,7 +254,7 @@ var _ = Describe("Library API", func() {
|
|||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
parts := Split(endpoint, " ")
|
||||
parts := strings.Split(endpoint, " ")
|
||||
method, path := parts[0], parts[1]
|
||||
|
||||
req := createAuthenticatedRequest(method, path, nil, userToken)
|
||||
|
|
@ -421,17 +422,3 @@ func createUnauthenticatedRequest(method, path string, body *bytes.Buffer) *http
|
|||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
// Helper function to split strings
|
||||
func Split(s, sep string) []string {
|
||||
var result []string
|
||||
start := 0
|
||||
for i, r := range s {
|
||||
if string(r) == sep {
|
||||
result = append(result, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
result = append(result, s[start:])
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue