mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
6 Commits
ca3d42f952
...
f3fa045d37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3fa045d37 | ||
|
|
ff583970f0 | ||
|
|
38ca65726a | ||
|
|
5ce6e16d96 | ||
|
|
69527085db | ||
|
|
07b0584a66 |
@ -72,7 +72,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, scannerScanner)
|
watcher := scanner.GetWatcher(dataStore, scannerScanner)
|
||||||
library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
|
library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
|
||||||
router := nativeapi.New(dataStore, share, playlists, insights, library)
|
maintenance := core.NewMaintenance(dataStore)
|
||||||
|
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -86,8 +86,7 @@ type configOptions struct {
|
|||||||
AuthRequestLimit int
|
AuthRequestLimit int
|
||||||
AuthWindowLength time.Duration
|
AuthWindowLength time.Duration
|
||||||
PasswordEncryptionKey string
|
PasswordEncryptionKey string
|
||||||
ReverseProxyUserHeader string
|
ExtAuth extAuthOptions
|
||||||
ReverseProxyWhitelist string
|
|
||||||
Plugins pluginsOptions
|
Plugins pluginsOptions
|
||||||
PluginConfig map[string]map[string]string
|
PluginConfig map[string]map[string]string
|
||||||
HTTPSecurityHeaders secureOptions `json:",omitzero"`
|
HTTPSecurityHeaders secureOptions `json:",omitzero"`
|
||||||
@ -226,6 +225,11 @@ type pluginsOptions struct {
|
|||||||
CacheSize string
|
CacheSize string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type extAuthOptions struct {
|
||||||
|
TrustedSources string
|
||||||
|
UserHeader string
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Server = &configOptions{}
|
Server = &configOptions{}
|
||||||
hooks []func()
|
hooks []func()
|
||||||
@ -243,6 +247,7 @@ func LoadFromFile(confFile string) {
|
|||||||
|
|
||||||
func Load(noConfigDump bool) {
|
func Load(noConfigDump bool) {
|
||||||
parseIniFileConfiguration()
|
parseIniFileConfiguration()
|
||||||
|
mapDeprecatedOptions()
|
||||||
|
|
||||||
err := viper.Unmarshal(&Server)
|
err := viper.Unmarshal(&Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -347,6 +352,7 @@ func Load(noConfigDump bool) {
|
|||||||
logDeprecatedOptions("Scanner.GenreSeparators")
|
logDeprecatedOptions("Scanner.GenreSeparators")
|
||||||
logDeprecatedOptions("Scanner.GroupAlbumReleases")
|
logDeprecatedOptions("Scanner.GroupAlbumReleases")
|
||||||
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||||
|
logDeprecatedOptions("ReverseProxyWhitelist", "ReverseProxyUserHeader")
|
||||||
|
|
||||||
// Call init hooks
|
// Call init hooks
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
@ -366,6 +372,17 @@ func logDeprecatedOptions(options ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mapDeprecatedOptions is used to provide backwards compatibility for deprecated options. It should be called after
|
||||||
|
// the config has been read by viper, but before unmarshalling it into the Config struct.
|
||||||
|
func mapDeprecatedOptions() {
|
||||||
|
if viper.IsSet("ReverseProxyWhitelist") {
|
||||||
|
viper.Set("ExtAuth.TrustedSources", viper.Get("ReverseProxyWhitelist"))
|
||||||
|
}
|
||||||
|
if viper.IsSet("ReverseProxyUserHeader") {
|
||||||
|
viper.Set("ExtAuth.UserHeader", viper.Get("ReverseProxyUserHeader"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
|
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
|
||||||
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
|
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
|
||||||
// section into the root level.
|
// section into the root level.
|
||||||
@ -533,8 +550,8 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("authrequestlimit", 5)
|
viper.SetDefault("authrequestlimit", 5)
|
||||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||||
viper.SetDefault("passwordencryptionkey", "")
|
viper.SetDefault("passwordencryptionkey", "")
|
||||||
viper.SetDefault("reverseproxyuserheader", "Remote-User")
|
viper.SetDefault("extauth.userheader", "Remote-User")
|
||||||
viper.SetDefault("reverseproxywhitelist", "")
|
viper.SetDefault("extauth.trustedsources", "")
|
||||||
viper.SetDefault("prometheus.enabled", false)
|
viper.SetDefault("prometheus.enabled", false)
|
||||||
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
||||||
viper.SetDefault("prometheus.password", "")
|
viper.SetDefault("prometheus.password", "")
|
||||||
|
|||||||
226
core/maintenance.go
Normal file
226
core/maintenance.go
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Maintenance interface {
|
||||||
|
// DeleteMissingFiles deletes specific missing files by their IDs
|
||||||
|
DeleteMissingFiles(ctx context.Context, ids []string) error
|
||||||
|
// DeleteAllMissingFiles deletes all files marked as missing
|
||||||
|
DeleteAllMissingFiles(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type maintenanceService struct {
|
||||||
|
ds model.DataStore
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMaintenance(ds model.DataStore) Maintenance {
|
||||||
|
return &maintenanceService{
|
||||||
|
ds: ds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error {
|
||||||
|
return s.deleteMissing(ctx, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error {
|
||||||
|
return s.deleteMissing(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations
|
||||||
|
func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error {
|
||||||
|
// Track affected album IDs before deletion for refresh
|
||||||
|
affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(ctx, "Error tracking affected albums for refresh", err)
|
||||||
|
// Don't fail the operation, just log the warning
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete missing files within a transaction
|
||||||
|
err = s.ds.WithTx(func(tx model.DataStore) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run garbage collection to clean up orphaned records
|
||||||
|
if err := s.ds.GC(ctx); err != nil {
|
||||||
|
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh statistics in background
|
||||||
|
s.refreshStatsAsync(ctx, affectedAlbumIDs)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
|
||||||
|
// It uses batch queries to minimize database round-trips for efficiency.
|
||||||
|
func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error {
|
||||||
|
if len(albumIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug(ctx, "Refreshing albums", "count", len(albumIDs))
|
||||||
|
|
||||||
|
// Process in chunks to avoid query size limits
|
||||||
|
const chunkSize = 100
|
||||||
|
for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) {
|
||||||
|
if err := s.refreshAlbumChunk(ctx, chunk); err != nil {
|
||||||
|
return fmt.Errorf("refreshing album chunk: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshAlbumChunk processes a single chunk of album IDs
|
||||||
|
func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
|
||||||
|
albumRepo := s.ds.Album(ctx)
|
||||||
|
mfRepo := s.ds.MediaFile(ctx)
|
||||||
|
|
||||||
|
// Batch load existing albums
|
||||||
|
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||||
|
Filters: squirrel.Eq{"album.id": albumIDs},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loading albums: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map for quick lookup
|
||||||
|
albumMap := make(map[string]*model.Album, len(albums))
|
||||||
|
for i := range albums {
|
||||||
|
albumMap[albums[i].ID] = &albums[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch load all media files for these albums
|
||||||
|
mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
|
||||||
|
Filters: squirrel.Eq{"album_id": albumIDs},
|
||||||
|
Sort: "album_id, path",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loading media files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group media files by album ID
|
||||||
|
filesByAlbum := make(map[string]model.MediaFiles)
|
||||||
|
for i := range mediaFiles {
|
||||||
|
albumID := mediaFiles[i].AlbumID
|
||||||
|
filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate each album from its media files
|
||||||
|
for albumID, oldAlbum := range albumMap {
|
||||||
|
mfs, hasTracks := filesByAlbum[albumID]
|
||||||
|
if !hasTracks {
|
||||||
|
// Album has no tracks anymore, skip (will be cleaned up by GC)
|
||||||
|
log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate album from media files
|
||||||
|
newAlbum := mfs.ToAlbum()
|
||||||
|
|
||||||
|
// Only update if something changed (avoid unnecessary writes)
|
||||||
|
if !oldAlbum.Equals(newAlbum) {
|
||||||
|
// Preserve original timestamps
|
||||||
|
newAlbum.UpdatedAt = time.Now()
|
||||||
|
newAlbum.CreatedAt = oldAlbum.CreatedAt
|
||||||
|
|
||||||
|
if err := albumRepo.Put(&newAlbum); err != nil {
|
||||||
|
log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
|
||||||
|
// Continue with other albums instead of failing entirely
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAffectedAlbumIDs returns distinct album IDs from missing media files
|
||||||
|
func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) {
|
||||||
|
var filters squirrel.Sqlizer = squirrel.Eq{"missing": true}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
filters = squirrel.And{
|
||||||
|
squirrel.Eq{"missing": true},
|
||||||
|
squirrel.Eq{"id": ids},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||||
|
Filters: filters,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract unique album IDs
|
||||||
|
albumIDMap := make(map[string]struct{}, len(mfs))
|
||||||
|
for _, mf := range mfs {
|
||||||
|
if mf.AlbumID != "" {
|
||||||
|
albumIDMap[mf.AlbumID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albumIDs := make([]string, 0, len(albumIDMap))
|
||||||
|
for id := range albumIDMap {
|
||||||
|
albumIDs = append(albumIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return albumIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshStatsAsync refreshes artist and album statistics in background goroutines
|
||||||
|
func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
|
||||||
|
// Refresh artist stats in background
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
bgCtx := request.AddValues(context.Background(), ctx)
|
||||||
|
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
||||||
|
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
||||||
|
} else {
|
||||||
|
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh album stats in background if we have affected albums
|
||||||
|
if len(affectedAlbumIDs) > 0 {
|
||||||
|
if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil {
|
||||||
|
log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err)
|
||||||
|
} else {
|
||||||
|
log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait waits for all background goroutines to complete.
|
||||||
|
// WARNING: This method is ONLY for testing. Never call this in production code.
|
||||||
|
// Calling Wait() in production will block until ALL background operations complete
|
||||||
|
// and may cause race conditions with new operations starting.
|
||||||
|
func (s *maintenanceService) wait() {
|
||||||
|
s.wg.Wait()
|
||||||
|
}
|
||||||
382
core/maintenance_test.go
Normal file
382
core/maintenance_test.go
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Maintenance", func() {
|
||||||
|
var ds *extendedDataStore
|
||||||
|
var mfRepo *extendedMediaFileRepo
|
||||||
|
var service Maintenance
|
||||||
|
var ctx context.Context
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx = context.Background()
|
||||||
|
ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true})
|
||||||
|
|
||||||
|
ds = createTestDataStore()
|
||||||
|
mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo)
|
||||||
|
service = NewMaintenance(ds)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("DeleteMissingFiles", func() {
|
||||||
|
Context("with specific IDs", func() {
|
||||||
|
It("deletes specific missing files and runs GC", func() {
|
||||||
|
// Setup: mock missing files with album IDs
|
||||||
|
mfRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||||
|
{ID: "mf2", AlbumID: "album2", Missing: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||||
|
Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"}))
|
||||||
|
Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("triggers artist stats refresh and album refresh after deletion", func() {
|
||||||
|
artistRepo := ds.MockedArtist.(*extendedArtistRepo)
|
||||||
|
// Setup: mock missing files with albums
|
||||||
|
albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
|
||||||
|
albumRepo.SetData(model.Albums{
|
||||||
|
{ID: "album1", Name: "Test Album", SongCount: 5},
|
||||||
|
})
|
||||||
|
mfRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||||
|
{ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
|
||||||
|
{ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Wait for background goroutines to complete
|
||||||
|
service.(*maintenanceService).wait()
|
||||||
|
|
||||||
|
// RefreshStats should be called
|
||||||
|
Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
|
||||||
|
|
||||||
|
// Album should be updated with new calculated values
|
||||||
|
Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error if deletion fails", func() {
|
||||||
|
mfRepo.deleteMissingError = errors.New("delete failed")
|
||||||
|
|
||||||
|
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||||
|
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("delete failed"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("continues even if album tracking fails", func() {
|
||||||
|
mfRepo.SetError(true)
|
||||||
|
|
||||||
|
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||||
|
|
||||||
|
// Should not fail, just log warning
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error if GC fails", func() {
|
||||||
|
mfRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set GC to return error
|
||||||
|
ds.gcError = errors.New("gc failed")
|
||||||
|
|
||||||
|
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||||
|
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("gc failed"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("album ID extraction", func() {
|
||||||
|
It("extracts unique album IDs from missing files", func() {
|
||||||
|
mfRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||||
|
{ID: "mf2", AlbumID: "album1", Missing: true},
|
||||||
|
{ID: "mf3", AlbumID: "album2", Missing: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"})
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips files without album IDs", func() {
|
||||||
|
mfRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "mf1", AlbumID: "", Missing: true},
|
||||||
|
{ID: "mf2", AlbumID: "album1", Missing: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("DeleteAllMissingFiles", func() {
|
||||||
|
It("deletes all missing files and runs GC", func() {
|
||||||
|
mfRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||||
|
{ID: "mf2", AlbumID: "album2", Missing: true},
|
||||||
|
{ID: "mf3", AlbumID: "album3", Missing: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := service.DeleteAllMissingFiles(ctx)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error if deletion fails", func() {
|
||||||
|
mfRepo.SetError(true)
|
||||||
|
|
||||||
|
err := service.DeleteAllMissingFiles(ctx)
|
||||||
|
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles empty result gracefully", func() {
|
||||||
|
mfRepo.SetData(model.MediaFiles{})
|
||||||
|
|
||||||
|
err := service.DeleteAllMissingFiles(ctx)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Album refresh logic", func() {
|
||||||
|
var albumRepo *extendedAlbumRepo
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
albumRepo = ds.MockedAlbum.(*extendedAlbumRepo)
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when album has no tracks after deletion", func() {
|
||||||
|
It("skips the album without updating it", func() {
|
||||||
|
// Setup album with no remaining tracks
|
||||||
|
albumRepo.SetData(model.Albums{
|
||||||
|
{ID: "album1", Name: "Empty Album", SongCount: 1},
|
||||||
|
})
|
||||||
|
mfRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Wait for background goroutines to complete
|
||||||
|
service.(*maintenanceService).wait()
|
||||||
|
|
||||||
|
// Album should NOT be updated because it has no tracks left
|
||||||
|
Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when Put fails for one album", func() {
|
||||||
|
It("continues processing other albums", func() {
|
||||||
|
albumRepo.SetData(model.Albums{
|
||||||
|
{ID: "album1", Name: "Album 1"},
|
||||||
|
{ID: "album2", Name: "Album 2"},
|
||||||
|
})
|
||||||
|
mfRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||||
|
{ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
|
||||||
|
{ID: "mf3", AlbumID: "album2", Missing: true},
|
||||||
|
{ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Make Put fail on first call but succeed on subsequent calls
|
||||||
|
albumRepo.putError = errors.New("put failed")
|
||||||
|
albumRepo.failOnce = true
|
||||||
|
|
||||||
|
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"})
|
||||||
|
|
||||||
|
// Should not fail even if one album's Put fails
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Wait for background goroutines to complete
|
||||||
|
service.(*maintenanceService).wait()
|
||||||
|
|
||||||
|
// Put should have been called multiple times
|
||||||
|
Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when media file loading fails", func() {
|
||||||
|
It("logs warning but continues when tracking affected albums fails", func() {
|
||||||
|
// Set up log capturing
|
||||||
|
hook, cleanup := tests.LogHook()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
albumRepo.SetData(model.Albums{
|
||||||
|
{ID: "album1", Name: "Album 1"},
|
||||||
|
})
|
||||||
|
mfRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||||
|
})
|
||||||
|
// Make GetAll fail when loading media files
|
||||||
|
mfRepo.SetError(true)
|
||||||
|
|
||||||
|
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||||
|
|
||||||
|
// Deletion should succeed despite the tracking error
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||||
|
|
||||||
|
// Verify the warning was logged
|
||||||
|
Expect(hook.LastEntry()).ToNot(BeNil())
|
||||||
|
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
|
||||||
|
Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test helper to create a mock DataStore with controllable behavior
|
||||||
|
func createTestDataStore() *extendedDataStore {
|
||||||
|
// Create extended datastore with GC tracking
|
||||||
|
ds := &extendedDataStore{
|
||||||
|
MockDataStore: &tests.MockDataStore{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create extended album repo with Put tracking
|
||||||
|
albumRepo := &extendedAlbumRepo{
|
||||||
|
MockAlbumRepo: tests.CreateMockAlbumRepo(),
|
||||||
|
}
|
||||||
|
ds.MockedAlbum = albumRepo
|
||||||
|
|
||||||
|
// Create extended artist repo with RefreshStats tracking
|
||||||
|
artistRepo := &extendedArtistRepo{
|
||||||
|
MockArtistRepo: tests.CreateMockArtistRepo(),
|
||||||
|
}
|
||||||
|
ds.MockedArtist = artistRepo
|
||||||
|
|
||||||
|
// Create extended media file repo with DeleteMissing support
|
||||||
|
mfRepo := &extendedMediaFileRepo{
|
||||||
|
MockMediaFileRepo: tests.CreateMockMediaFileRepo(),
|
||||||
|
}
|
||||||
|
ds.MockedMediaFile = mfRepo
|
||||||
|
|
||||||
|
return ds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension of MockMediaFileRepo to add DeleteMissing method
|
||||||
|
type extendedMediaFileRepo struct {
|
||||||
|
*tests.MockMediaFileRepo
|
||||||
|
deleteMissingCalled bool
|
||||||
|
deletedIDs []string
|
||||||
|
deleteMissingError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
|
||||||
|
m.deleteMissingCalled = true
|
||||||
|
m.deletedIDs = ids
|
||||||
|
if m.deleteMissingError != nil {
|
||||||
|
return m.deleteMissingError
|
||||||
|
}
|
||||||
|
// Actually delete from the mock data
|
||||||
|
for _, id := range ids {
|
||||||
|
delete(m.Data, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension of MockAlbumRepo to track Put calls
|
||||||
|
type extendedAlbumRepo struct {
|
||||||
|
*tests.MockAlbumRepo
|
||||||
|
mu sync.RWMutex
|
||||||
|
putCallCount int
|
||||||
|
lastPutData *model.Album
|
||||||
|
putError error
|
||||||
|
failOnce bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extendedAlbumRepo) Put(album *model.Album) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.putCallCount++
|
||||||
|
m.lastPutData = album
|
||||||
|
|
||||||
|
// Handle failOnce behavior
|
||||||
|
var err error
|
||||||
|
if m.putError != nil {
|
||||||
|
if m.failOnce {
|
||||||
|
err = m.putError
|
||||||
|
m.putError = nil // Clear error after first failure
|
||||||
|
m.mu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = m.putError
|
||||||
|
m.mu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return m.MockAlbumRepo.Put(album)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extendedAlbumRepo) GetPutCallCount() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.putCallCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension of MockArtistRepo to track RefreshStats calls
|
||||||
|
type extendedArtistRepo struct {
|
||||||
|
*tests.MockArtistRepo
|
||||||
|
mu sync.RWMutex
|
||||||
|
refreshStatsCalled bool
|
||||||
|
refreshStatsError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.refreshStatsCalled = true
|
||||||
|
err := m.refreshStatsError
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return m.MockArtistRepo.RefreshStats(allArtists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.refreshStatsCalled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension of MockDataStore to track GC calls
|
||||||
|
type extendedDataStore struct {
|
||||||
|
*tests.MockDataStore
|
||||||
|
gcCalled bool
|
||||||
|
gcError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *extendedDataStore) GC(ctx context.Context) error {
|
||||||
|
ds.gcCalled = true
|
||||||
|
if ds.gcError != nil {
|
||||||
|
return ds.gcError
|
||||||
|
}
|
||||||
|
return ds.MockDataStore.GC(ctx)
|
||||||
|
}
|
||||||
@ -207,7 +207,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
|||||||
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
|
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
|
||||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||||
data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != ""
|
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
||||||
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
|
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
|
||||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ var Set = wire.NewSet(
|
|||||||
NewShare,
|
NewShare,
|
||||||
NewPlaylists,
|
NewPlaylists,
|
||||||
NewLibrary,
|
NewLibrary,
|
||||||
|
NewMaintenance,
|
||||||
agents.GetAgents,
|
agents.GetAgents,
|
||||||
external.NewProvider,
|
external.NewProvider,
|
||||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||||
|
|||||||
14
go.mod
14
go.mod
@ -2,12 +2,8 @@ module github.com/navidrome/navidrome
|
|||||||
|
|
||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
replace (
|
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
|
||||||
// Using version from main that fixes https://github.com/navidrome/navidrome/issues/4396
|
|
||||||
github.com/tetratelabs/wazero v1.9.0 => github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Masterminds/squirrel v1.5.4
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
@ -60,15 +56,15 @@ require (
|
|||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tetratelabs/wazero v1.9.0
|
github.com/tetratelabs/wazero v1.10.0
|
||||||
github.com/unrolled/secure v1.17.0
|
github.com/unrolled/secure v1.17.0
|
||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||||
go.uber.org/goleak v1.3.0
|
go.uber.org/goleak v1.3.0
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
||||||
golang.org/x/image v0.32.0
|
golang.org/x/image v0.32.0
|
||||||
golang.org/x/net v0.46.0
|
golang.org/x/net v0.46.0
|
||||||
golang.org/x/sync v0.17.0
|
golang.org/x/sync v0.18.0
|
||||||
golang.org/x/sys v0.37.0
|
golang.org/x/sys v0.38.0
|
||||||
golang.org/x/text v0.30.0
|
golang.org/x/text v0.30.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.10
|
||||||
|
|||||||
12
go.sum
12
go.sum
@ -265,8 +265,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684 h1:ugT1JTRsK1Jhn95BWilCugyZ1Svsyxm9xSiflOa2e7E=
|
github.com/tetratelabs/wazero v1.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk=
|
||||||
github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
|
github.com/tetratelabs/wazero v1.10.0/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
@ -332,8 +332,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@ -350,8 +350,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
|
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
|
||||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
|
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
|
||||||
|
|||||||
18
log/log.go
18
log/log.go
@ -11,6 +11,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@ -28,8 +29,8 @@ var redacted = &Hook{
|
|||||||
"(Secret:\")[\\w]*",
|
"(Secret:\")[\\w]*",
|
||||||
"(Spotify.*ID:\")[\\w]*",
|
"(Spotify.*ID:\")[\\w]*",
|
||||||
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
|
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
|
||||||
"(ReverseProxyUserHeader:[\\s]*\")[^\"]*",
|
"(UserHeader:[\\s]*\")[^\"]*",
|
||||||
"(ReverseProxyWhitelist:[\\s]*\")[^\"]*",
|
"(TrustedSources:[\\s]*\")[^\"]*",
|
||||||
"(MetricsPath:[\\s]*\")[^\"]*",
|
"(MetricsPath:[\\s]*\")[^\"]*",
|
||||||
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
|
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
|
||||||
"(DevAutoLoginUsername:[\\s]*\")[^\"]*",
|
"(DevAutoLoginUsername:[\\s]*\")[^\"]*",
|
||||||
@ -70,6 +71,7 @@ type levelPath struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
currentLevel Level
|
currentLevel Level
|
||||||
|
loggerMu sync.RWMutex
|
||||||
defaultLogger = logrus.New()
|
defaultLogger = logrus.New()
|
||||||
logSourceLine = false
|
logSourceLine = false
|
||||||
rootPath string
|
rootPath string
|
||||||
@ -79,7 +81,9 @@ var (
|
|||||||
// SetLevel sets the global log level used by the simple logger.
|
// SetLevel sets the global log level used by the simple logger.
|
||||||
func SetLevel(l Level) {
|
func SetLevel(l Level) {
|
||||||
currentLevel = l
|
currentLevel = l
|
||||||
|
loggerMu.Lock()
|
||||||
defaultLogger.Level = logrus.TraceLevel
|
defaultLogger.Level = logrus.TraceLevel
|
||||||
|
loggerMu.Unlock()
|
||||||
logrus.SetLevel(logrus.Level(l))
|
logrus.SetLevel(logrus.Level(l))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,6 +129,8 @@ func SetLogSourceLine(enabled bool) {
|
|||||||
|
|
||||||
func SetRedacting(enabled bool) {
|
func SetRedacting(enabled bool) {
|
||||||
if enabled {
|
if enabled {
|
||||||
|
loggerMu.Lock()
|
||||||
|
defer loggerMu.Unlock()
|
||||||
defaultLogger.AddHook(redacted)
|
defaultLogger.AddHook(redacted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -133,6 +139,8 @@ func SetOutput(w io.Writer) {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
w = CRLFWriter(w)
|
w = CRLFWriter(w)
|
||||||
}
|
}
|
||||||
|
loggerMu.Lock()
|
||||||
|
defer loggerMu.Unlock()
|
||||||
defaultLogger.SetOutput(w)
|
defaultLogger.SetOutput(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,6 +166,8 @@ func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Conte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SetDefaultLogger(l *logrus.Logger) {
|
func SetDefaultLogger(l *logrus.Logger) {
|
||||||
|
loggerMu.Lock()
|
||||||
|
defer loggerMu.Unlock()
|
||||||
defaultLogger = l
|
defaultLogger = l
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,6 +214,8 @@ func log(level Level, args ...interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Writer() io.Writer {
|
func Writer() io.Writer {
|
||||||
|
loggerMu.RLock()
|
||||||
|
defer loggerMu.RUnlock()
|
||||||
return defaultLogger.Writer()
|
return defaultLogger.Writer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -314,6 +326,8 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
|
|||||||
func createNewLogger() *logrus.Entry {
|
func createNewLogger() *logrus.Entry {
|
||||||
//logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true})
|
//logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true})
|
||||||
//l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}
|
//l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}
|
||||||
|
loggerMu.RLock()
|
||||||
|
defer loggerMu.RUnlock()
|
||||||
logger := logrus.NewEntry(defaultLogger)
|
logger := logrus.NewEntry(defaultLogger)
|
||||||
return logger
|
return logger
|
||||||
}
|
}
|
||||||
|
|||||||
@ -193,24 +193,24 @@ func UsernameFromToken(r *http.Request) string {
|
|||||||
return token.Subject()
|
return token.Subject()
|
||||||
}
|
}
|
||||||
|
|
||||||
func UsernameFromReverseProxyHeader(r *http.Request) string {
|
func UsernameFromExtAuthHeader(r *http.Request) string {
|
||||||
if conf.Server.ReverseProxyWhitelist == "" {
|
if conf.Server.ExtAuth.TrustedSources == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
|
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Error("ReverseProxyWhitelist enabled but no proxy IP found in request context. Please report this error.")
|
log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if !validateIPAgainstList(reverseProxyIp, conf.Server.ReverseProxyWhitelist) {
|
if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
|
||||||
log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
|
log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
username := r.Header.Get(conf.Server.ReverseProxyUserHeader)
|
username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username)
|
log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
|
||||||
return username
|
return username
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,7 +256,7 @@ func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ..
|
|||||||
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromReverseProxyHeader)
|
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||||
return
|
return
|
||||||
@ -291,7 +291,7 @@ func JWTRefresher(next http.Handler) http.Handler {
|
|||||||
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
|
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
|
||||||
username := UsernameFromConfig(r)
|
username := UsernameFromConfig(r)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
username = UsernameFromReverseProxyHeader(r)
|
username = UsernameFromExtAuthHeader(r)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,7 +80,7 @@ var _ = Describe("Auth", func() {
|
|||||||
req.Header.Add("Remote-User", "janedoe")
|
req.Header.Add("Remote-User", "janedoe")
|
||||||
resp = httptest.NewRecorder()
|
resp = httptest.NewRecorder()
|
||||||
conf.Server.UILoginBackgroundURL = ""
|
conf.Server.UILoginBackgroundURL = ""
|
||||||
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48"
|
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48"
|
||||||
})
|
})
|
||||||
|
|
||||||
It("sets auth data if IPv4 matches whitelist", func() {
|
It("sets auth data if IPv4 matches whitelist", func() {
|
||||||
@ -155,7 +155,7 @@ var _ = Describe("Auth", func() {
|
|||||||
|
|
||||||
It("does not set auth data when listening on unix socket without whitelist", func() {
|
It("does not set auth data when listening on unix socket without whitelist", func() {
|
||||||
conf.Server.Address = "unix:/tmp/navidrome-test"
|
conf.Server.Address = "unix:/tmp/navidrome-test"
|
||||||
conf.Server.ReverseProxyWhitelist = ""
|
conf.Server.ExtAuth.TrustedSources = ""
|
||||||
|
|
||||||
// No ReverseProxyIp in request context
|
// No ReverseProxyIp in request context
|
||||||
serveIndex(ds, fs, nil)(resp, req)
|
serveIndex(ds, fs, nil)(resp, req)
|
||||||
@ -176,7 +176,7 @@ var _ = Describe("Auth", func() {
|
|||||||
|
|
||||||
It("sets auth data when listening on unix socket with correct whitelist", func() {
|
It("sets auth data when listening on unix socket with correct whitelist", func() {
|
||||||
conf.Server.Address = "unix:/tmp/navidrome-test"
|
conf.Server.Address = "unix:/tmp/navidrome-test"
|
||||||
conf.Server.ReverseProxyWhitelist = conf.Server.ReverseProxyWhitelist + ",@"
|
conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@"
|
||||||
|
|
||||||
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
|
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
|
||||||
serveIndex(ds, fs, nil)(resp, req)
|
serveIndex(ds, fs, nil)(resp, req)
|
||||||
@ -302,8 +302,8 @@ var _ = Describe("Auth", func() {
|
|||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
req = httptest.NewRequest("GET", "/", nil)
|
req = httptest.NewRequest("GET", "/", nil)
|
||||||
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
|
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
|
||||||
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16"
|
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
|
||||||
conf.Server.ReverseProxyUserHeader = "Remote-User"
|
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
||||||
})
|
})
|
||||||
|
|
||||||
It("makes the first user an admin", func() {
|
It("makes the first user an admin", func() {
|
||||||
|
|||||||
@ -168,7 +168,7 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
|
|||||||
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
|
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
|
||||||
// context if navidrome is behind a trusted reverse proxy.
|
// context if navidrome is behind a trusted reverse proxy.
|
||||||
func realIPMiddleware(next http.Handler) http.Handler {
|
func realIPMiddleware(next http.Handler) http.Handler {
|
||||||
if conf.Server.ReverseProxyWhitelist != "" {
|
if conf.Server.ExtAuth.TrustedSources != "" {
|
||||||
return chi.Chain(
|
return chi.Chain(
|
||||||
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
|
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
|
||||||
middleware.RealIP,
|
middleware.RealIP,
|
||||||
|
|||||||
@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
|
|||||||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
|
|
||||||
// Create test users
|
// Create test users
|
||||||
|
|||||||
@ -13,11 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// User-library association endpoints (admin only)
|
// User-library association endpoints (admin only)
|
||||||
func (n *Router) addUserLibraryRoute(r chi.Router) {
|
func (api *Router) addUserLibraryRoute(r chi.Router) {
|
||||||
r.Route("/user/{id}/library", func(r chi.Router) {
|
r.Route("/user/{id}/library", func(r chi.Router) {
|
||||||
r.Use(parseUserIDMiddleware)
|
r.Use(parseUserIDMiddleware)
|
||||||
r.Get("/", getUserLibraries(n.libs))
|
r.Get("/", getUserLibraries(api.libs))
|
||||||
r.Put("/", setUserLibraries(n.libs))
|
r.Put("/", setUserLibraries(api.libs))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ var _ = Describe("Library API", func() {
|
|||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
|
|
||||||
// Create test users
|
// Create test users
|
||||||
|
|||||||
@ -8,9 +8,9 @@ import (
|
|||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -63,45 +63,32 @@ func (r *missingRepository) EntityName() string {
|
|||||||
return "missing_files"
|
return "missing_files"
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
|
func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
|
||||||
ctx := r.Context()
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
p := req.Params(r)
|
ctx := r.Context()
|
||||||
ids, _ := p.Strings("id")
|
|
||||||
err := ds.WithTx(func(tx model.DataStore) error {
|
p := req.Params(r)
|
||||||
|
ids, _ := p.Strings("id")
|
||||||
|
|
||||||
|
var err error
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
err = maintenance.DeleteAllMissingFiles(ctx)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
|
||||||
})
|
|
||||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
|
||||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = ds.GC(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh artist stats in background after deleting missing files
|
|
||||||
go func() {
|
|
||||||
bgCtx := request.AddValues(context.Background(), r.Context())
|
|
||||||
if _, err := ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
|
||||||
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
|
||||||
} else {
|
} else {
|
||||||
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
err = maintenance.DeleteMissingFiles(ctx, ids)
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
writeDeleteManyResponse(w, r, ids)
|
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||||
|
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to delete missing files", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeDeleteManyResponse(w, r, ids)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ model.ResourceRepository = &missingRepository{}
|
var _ model.ResourceRepository = &missingRepository{}
|
||||||
|
|||||||
@ -22,70 +22,71 @@ import (
|
|||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
http.Handler
|
http.Handler
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
share core.Share
|
share core.Share
|
||||||
playlists core.Playlists
|
playlists core.Playlists
|
||||||
insights metrics.Insights
|
insights metrics.Insights
|
||||||
libs core.Library
|
libs core.Library
|
||||||
|
maintenance core.Maintenance
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library) *Router {
|
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router {
|
||||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService}
|
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) routes() http.Handler {
|
func (api *Router) routes() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
n.RX(r, "/translation", newTranslationRepository, false)
|
api.RX(r, "/translation", newTranslationRepository, false)
|
||||||
|
|
||||||
// Protected
|
// Protected
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(server.Authenticator(n.ds))
|
r.Use(server.Authenticator(api.ds))
|
||||||
r.Use(server.JWTRefresher)
|
r.Use(server.JWTRefresher)
|
||||||
r.Use(server.UpdateLastAccessMiddleware(n.ds))
|
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||||
n.R(r, "/user", model.User{}, true)
|
api.R(r, "/user", model.User{}, true)
|
||||||
n.R(r, "/song", model.MediaFile{}, false)
|
api.R(r, "/song", model.MediaFile{}, false)
|
||||||
n.R(r, "/album", model.Album{}, false)
|
api.R(r, "/album", model.Album{}, false)
|
||||||
n.R(r, "/artist", model.Artist{}, false)
|
api.R(r, "/artist", model.Artist{}, false)
|
||||||
n.R(r, "/genre", model.Genre{}, false)
|
api.R(r, "/genre", model.Genre{}, false)
|
||||||
n.R(r, "/player", model.Player{}, true)
|
api.R(r, "/player", model.Player{}, true)
|
||||||
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||||
n.R(r, "/radio", model.Radio{}, true)
|
api.R(r, "/radio", model.Radio{}, true)
|
||||||
n.R(r, "/tag", model.Tag{}, true)
|
api.R(r, "/tag", model.Tag{}, true)
|
||||||
if conf.Server.EnableSharing {
|
if conf.Server.EnableSharing {
|
||||||
n.RX(r, "/share", n.share.NewRepository, true)
|
api.RX(r, "/share", api.share.NewRepository, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
n.addPlaylistRoute(r)
|
api.addPlaylistRoute(r)
|
||||||
n.addPlaylistTrackRoute(r)
|
api.addPlaylistTrackRoute(r)
|
||||||
n.addSongPlaylistsRoute(r)
|
api.addSongPlaylistsRoute(r)
|
||||||
n.addQueueRoute(r)
|
api.addQueueRoute(r)
|
||||||
n.addMissingFilesRoute(r)
|
api.addMissingFilesRoute(r)
|
||||||
n.addKeepAliveRoute(r)
|
api.addKeepAliveRoute(r)
|
||||||
n.addInsightsRoute(r)
|
api.addInsightsRoute(r)
|
||||||
|
|
||||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||||
n.addInspectRoute(r)
|
api.addInspectRoute(r)
|
||||||
n.addConfigRoute(r)
|
api.addConfigRoute(r)
|
||||||
n.addUserLibraryRoute(r)
|
api.addUserLibraryRoute(r)
|
||||||
n.RX(r, "/library", n.libs.NewRepository, true)
|
api.RX(r, "/library", api.libs.NewRepository, true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
|
func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
|
||||||
constructor := func(ctx context.Context) rest.Repository {
|
constructor := func(ctx context.Context) rest.Repository {
|
||||||
return n.ds.Resource(ctx, model)
|
return api.ds.Resource(ctx, model)
|
||||||
}
|
}
|
||||||
n.RX(r, pathPrefix, constructor, persistable)
|
api.RX(r, pathPrefix, constructor, persistable)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||||
r.Route(pathPrefix, func(r chi.Router) {
|
r.Route(pathPrefix, func(r chi.Router) {
|
||||||
r.Get("/", rest.GetAll(constructor))
|
r.Get("/", rest.GetAll(constructor))
|
||||||
if persistable {
|
if persistable {
|
||||||
@ -102,9 +103,9 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) addPlaylistRoute(r chi.Router) {
|
func (api *Router) addPlaylistRoute(r chi.Router) {
|
||||||
constructor := func(ctx context.Context) rest.Repository {
|
constructor := func(ctx context.Context) rest.Repository {
|
||||||
return n.ds.Resource(ctx, model.Playlist{})
|
return api.ds.Resource(ctx, model.Playlist{})
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Route("/playlist", func(r chi.Router) {
|
r.Route("/playlist", func(r chi.Router) {
|
||||||
@ -114,7 +115,7 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
|
|||||||
rest.Post(constructor)(w, r)
|
rest.Post(constructor)(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
createPlaylistFromM3U(n.playlists)(w, r)
|
createPlaylistFromM3U(api.playlists)(w, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/{id}", func(r chi.Router) {
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
@ -126,55 +127,53 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) addPlaylistTrackRoute(r chi.Router) {
|
func (api *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||||
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
||||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
getPlaylist(n.ds)(w, r)
|
getPlaylist(api.ds)(w, r)
|
||||||
})
|
})
|
||||||
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
|
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
|
||||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
deleteFromPlaylist(n.ds)(w, r)
|
deleteFromPlaylist(api.ds)(w, r)
|
||||||
})
|
})
|
||||||
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
addToPlaylist(n.ds)(w, r)
|
addToPlaylist(api.ds)(w, r)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
r.Route("/{id}", func(r chi.Router) {
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
r.Use(server.URLParamsMiddleware)
|
r.Use(server.URLParamsMiddleware)
|
||||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
getPlaylistTrack(n.ds)(w, r)
|
getPlaylistTrack(api.ds)(w, r)
|
||||||
})
|
})
|
||||||
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
reorderItem(n.ds)(w, r)
|
reorderItem(api.ds)(w, r)
|
||||||
})
|
})
|
||||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
deleteFromPlaylist(n.ds)(w, r)
|
deleteFromPlaylist(api.ds)(w, r)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) addSongPlaylistsRoute(r chi.Router) {
|
func (api *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||||
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
|
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
|
||||||
getSongPlaylists(n.ds)(w, r)
|
getSongPlaylists(api.ds)(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) addQueueRoute(r chi.Router) {
|
func (api *Router) addQueueRoute(r chi.Router) {
|
||||||
r.Route("/queue", func(r chi.Router) {
|
r.Route("/queue", func(r chi.Router) {
|
||||||
r.Get("/", getQueue(n.ds))
|
r.Get("/", getQueue(api.ds))
|
||||||
r.Post("/", saveQueue(n.ds))
|
r.Post("/", saveQueue(api.ds))
|
||||||
r.Put("/", updateQueue(n.ds))
|
r.Put("/", updateQueue(api.ds))
|
||||||
r.Delete("/", clearQueue(n.ds))
|
r.Delete("/", clearQueue(api.ds))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
func (api *Router) addMissingFilesRoute(r chi.Router) {
|
||||||
r.Route("/missing", func(r chi.Router) {
|
r.Route("/missing", func(r chi.Router) {
|
||||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
api.RX(r, "/", newMissingRepository(api.ds), false)
|
||||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Delete("/", deleteMissingFiles(api.maintenance))
|
||||||
deleteMissingFiles(n.ds, w, r)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,7 +197,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) addInspectRoute(r chi.Router) {
|
func (api *Router) addInspectRoute(r chi.Router) {
|
||||||
if conf.Server.Inspect.Enabled {
|
if conf.Server.Inspect.Enabled {
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
if conf.Server.Inspect.MaxRequests > 0 {
|
if conf.Server.Inspect.MaxRequests > 0 {
|
||||||
@ -207,26 +206,26 @@ func (n *Router) addInspectRoute(r chi.Router) {
|
|||||||
conf.Server.Inspect.BacklogTimeout)
|
conf.Server.Inspect.BacklogTimeout)
|
||||||
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
|
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
|
||||||
}
|
}
|
||||||
r.Get("/inspect", inspect(n.ds))
|
r.Get("/inspect", inspect(api.ds))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) addConfigRoute(r chi.Router) {
|
func (api *Router) addConfigRoute(r chi.Router) {
|
||||||
if conf.Server.DevUIShowConfig {
|
if conf.Server.DevUIShowConfig {
|
||||||
r.Get("/config/*", getConfig)
|
r.Get("/config/*", getConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) addKeepAliveRoute(r chi.Router) {
|
func (api *Router) addKeepAliveRoute(r chi.Router) {
|
||||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Router) addInsightsRoute(r chi.Router) {
|
func (api *Router) addInsightsRoute(r chi.Router) {
|
||||||
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
last, success := n.insights.LastRun(r.Context())
|
last, success := api.insights.LastRun(r.Context())
|
||||||
if conf.Server.EnableInsightsCollector {
|
if conf.Server.EnableInsightsCollector {
|
||||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
|
|||||||
mfRepo.SetData(testSongs)
|
mfRepo.SetData(testSongs)
|
||||||
|
|
||||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -56,7 +56,7 @@ func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
|
|||||||
return username, true
|
return username, true
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.UsernameFromReverseProxyHeader(r), false
|
return server.UsernameFromExtAuthHeader(r), false
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkRequiredParameters(next http.Handler) http.Handler {
|
func checkRequiredParameters(next http.Handler) http.Handler {
|
||||||
|
|||||||
@ -95,8 +95,8 @@ var _ = Describe("Middlewares", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("passes when all required params are available (reverse-proxy case)", func() {
|
It("passes when all required params are available (reverse-proxy case)", func() {
|
||||||
conf.Server.ReverseProxyWhitelist = "127.0.0.234/32"
|
conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
|
||||||
conf.Server.ReverseProxyUserHeader = "Remote-User"
|
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
||||||
|
|
||||||
r := newGetRequest("v=1.15", "c=test")
|
r := newGetRequest("v=1.15", "c=test")
|
||||||
r.Header.Add("Remote-User", "user")
|
r.Header.Add("Remote-User", "user")
|
||||||
@ -254,8 +254,8 @@ var _ = Describe("Middlewares", func() {
|
|||||||
When("using reverse proxy authentication", func() {
|
When("using reverse proxy authentication", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.ReverseProxyWhitelist = "192.168.1.1/24"
|
conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
|
||||||
conf.Server.ReverseProxyUserHeader = "Remote-User"
|
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
||||||
})
|
})
|
||||||
|
|
||||||
It("passes authentication with correct IP and header", func() {
|
It("passes authentication with correct IP and header", func() {
|
||||||
|
|||||||
@ -6,7 +6,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model/id"
|
"github.com/navidrome/navidrome/model/id"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testingT interface {
|
type testingT interface {
|
||||||
@ -35,3 +38,23 @@ func ClearDB() error {
|
|||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LogHook sets up a logrus test hook and configures the default logger to use it.
|
||||||
|
// It returns the hook and a cleanup function to restore the default logger.
|
||||||
|
// Example usage:
|
||||||
|
//
|
||||||
|
// hook, cleanup := LogHook()
|
||||||
|
// defer cleanup()
|
||||||
|
// // ... perform logging operations ...
|
||||||
|
// Expect(hook.LastEntry()).ToNot(BeNil())
|
||||||
|
// Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
|
||||||
|
// Expect(hook.LastEntry().Message).To(Equal("log message"))
|
||||||
|
func LogHook() (*test.Hook, func()) {
|
||||||
|
l, hook := test.NewNullLogger()
|
||||||
|
log.SetLevel(log.LevelWarn)
|
||||||
|
log.SetDefaultLogger(l)
|
||||||
|
return hook, func() {
|
||||||
|
// Restore default logger after test
|
||||||
|
log.SetDefaultLogger(logrus.New())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export default {
|
|||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: '#f0f2f5',
|
default: '#f0f2f5',
|
||||||
paper: 'inherit',
|
paper: bLight['500'],
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
secondary: '#232323',
|
secondary: '#232323',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user