Compare commits

..

5 commits

Author SHA1 Message Date
Junker der Provinz
2ce7ef53ca
test(scanner): run multi-library and selective scan specs on Windows (#5381) (#6307)
* test(scanner): run multi-library and selective scan specs on Windows

Both suites put their DB in Ginkgo's TempDir, but the shared db.Db() pool stays open until TestScanner returns, so on Windows the TempDir cleanup failed with a sharing violation. Use the same os.MkdirTemp plus best-effort RemoveAll setup as scanner_test.go and drop the skips.

Signed-off-by: junkerderprovinz <jdp@braethoria.com>

* test(scanner): say why the DB may stay open in the temp dir

Signed-off-by: junkerderprovinz <jdp@braethoria.com>

---------

Signed-off-by: junkerderprovinz <jdp@braethoria.com>
Co-authored-by: junkerderprovinz <jdp@braethoria.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-11-13 22:58:10 -05:00
Kris Anderson
88adc02f74
fix(ui): skip the login form when already signed in (#6459)
* fix(ui): skip the login form when already signed in

Opening #/login while signed in always showed the login form, so a
bookmarked or browser-autocompleted login URL asked for the password on
every visit although the stored session was still valid. Redirect to
the app when checkAuth succeeds, as react-admin's default login page
does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <1562427+kris-anderson@users.noreply.github.com>

* fix(ui): replace the login history entry on redirect

Pushing / left #/login one step back in history, so pressing Back
landed on the login page, which redirected forward again and kept the
user from returning to the previous page. Replace the entry instead.
Raised in review by Codex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <1562427+kris-anderson@users.noreply.github.com>

* chore(ui): shorten the login redirect comment

Background moved here from the code comment: react-admin's default login
page sends an already signed-in user to the app, and Navidrome's custom
Login.jsx had dropped that check. Opening #/login from a bookmark or the
browser's autocomplete therefore showed the password form although the
stored session was still valid. The redirect uses replace so Back
returns to the previous page instead of landing on #/login again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <1562427+kris-anderson@users.noreply.github.com>

---------

Signed-off-by: Kris Anderson <1562427+kris-anderson@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-11-13 20:16:40 -05:00
Deluan Quintão
3a40f29aa5
feat(scanner): add option to skip folders containing a .nomedia file (#6139) 2026-11-13 19:38:14 -05:00
Deluan Quintão
55e52b7ce4
fix(server): skip the startup music folder write when the path is unchanged (#6457) 2026-11-13 17:13:15 -05:00
Deluan Quintão
6177c92e32
test(scanner): restore DbPath after Ordered containers and reset runPhase counter (#6456) 2026-11-13 15:21:14 -05:00
22 changed files with 334 additions and 209 deletions

View file

@ -176,6 +176,7 @@ type scannerOptions struct {
GroupAlbumReleases bool // Deprecated: Use PID.Album instead GroupAlbumReleases bool // Deprecated: Use PID.Album instead
FollowSymlinks bool // Whether to follow symlinks when scanning directories FollowSymlinks bool // Whether to follow symlinks when scanning directories
IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning
RespectNoMedia bool // Whether to skip folders (and their subfolders) containing a .nomedia file
PurgeMissing string // Values: "never", "always", "full" PurgeMissing string // Values: "never", "always", "full"
} }
@ -1054,6 +1055,7 @@ func setViperDefaults() {
viper.SetDefault("scanner.groupalbumreleases", false) viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true) viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("scanner.ignoredotfolders", true) viper.SetDefault("scanner.ignoredotfolders", true)
viper.SetDefault("scanner.respectnomedia", false)
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever) viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.appendalbumversion", true) viper.SetDefault("subsonic.appendalbumversion", true)

View file

@ -90,6 +90,7 @@ const (
I18nFolder = "i18n" I18nFolder = "i18n"
ScanIgnoreFile = ".ndignore" ScanIgnoreFile = ".ndignore"
NoMediaFile = ".nomedia"
ArtworkFolder = "artwork" ArtworkFolder = "artwork"
// HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed // HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed
// upload folders beside it so Prune's sweep never reaches them. // upload folders beside it so Prune's sweep never reaches them.

View file

@ -136,10 +136,14 @@ ON CONFLICT (user_id, library_id) DO NOTHING;`,
// TODO Remove this method when we have a proper UI to add libraries // TODO Remove this method when we have a proper UI to add libraries
// This is a temporary method to store the music folder path from the config in the DB // This is a temporary method to store the music folder path from the config in the DB
func (r *libraryRepository) StoreMusicFolder(ctx context.Context) error { func (r *libraryRepository) StoreMusicFolder(ctx context.Context) error {
changed := And{Eq{"id": model.DefaultLibraryID}, NotEq{"path": conf.Server().MusicFolder}}
// An UPDATE takes the write lock even when nothing matches, and a startup scan may be holding it
if needsUpdate, err := r.exists(ctx, changed); err != nil || !needsUpdate {
return err
}
sq := Update(r.tableName).Set("path", conf.Server().MusicFolder). sq := Update(r.tableName).Set("path", conf.Server().MusicFolder).
Set("updated_at", time.Now()). Set("updated_at", time.Now()).
Where(Eq{"id": model.DefaultLibraryID}). Where(changed)
Where(NotEq{"path": conf.Server().MusicFolder})
rowsAffected, err := r.executeSQL(ctx, sq) rowsAffected, err := r.executeSQL(ctx, sq)
if err == nil && rowsAffected > 0 { if err == nil && rowsAffected > 0 {
libLock.Lock() libLock.Lock()

View file

@ -6,6 +6,7 @@ import (
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
"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/model/request"
@ -168,6 +169,18 @@ var _ = Describe("LibraryRepository", func() {
Expect(libAfter.UpdatedAt).To(Equal(libBefore.UpdatedAt)) Expect(libAfter.UpdatedAt).To(Equal(libBefore.UpdatedAt))
}) })
It("does not need the write lock when the configured path is unchanged", func() {
conf.Update(func(c *conf.Options) { c.MusicFolder = libBefore.Path })
lockConn, err := db.Db().Conn(ctx)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(lockConn.Close)
_, err = lockConn.ExecContext(ctx, "BEGIN IMMEDIATE")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _, _ = lockConn.ExecContext(context.Background(), "ROLLBACK") })
Expect(repo.StoreMusicFolder(ctx)).To(Succeed())
})
It("updates the default library only when the configured path changes", func() { It("updates the default library only when the configured path changes", func() {
conf.Update(func(c *conf.Options) { c.MusicFolder = libBefore.Path + "-updated" }) conf.Update(func(c *conf.Options) { c.MusicFolder = libBefore.Path + "-updated" })
Expect(repo.StoreMusicFolder(ctx)).To(Succeed()) Expect(repo.StoreMusicFolder(ctx)).To(Succeed())

View file

@ -96,8 +96,7 @@ func NewMediaFileRepository(db dbx.Builder) model.MediaFileRepository {
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number", "album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"), "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"),
"random": "random", "random": "random",
"created_at": "media_file.created_at, media_file.id", "created_at": "media_file.created_at",
"updated_at": "media_file.updated_at, media_file.id",
"recently_added": mediaFileRecentlyAddedSort(), "recently_added": mediaFileRecentlyAddedSort(),
"starred_at": "starred, starred_at", "starred_at": "starred, starred_at",
"rated_at": "rating, rated_at", "rated_at": "rating, rated_at",

View file

@ -449,7 +449,9 @@ func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.S
// queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results // queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results
// from a cursor, guaranteeing that the results will be stable, even if the underlying data changes. // from a cursor, guaranteeing that the results will be stable, even if the underlying data changes.
func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) { func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) {
sq = r.paginate(sq, options...) if len(options) > 0 && options[0].Offset > 0 {
sq = r.optimizePagination(sq, options[0])
}
query, args, err := r.toSQL(sq) query, args, err := r.toSQL(sq)
if err != nil { if err != nil {
return nil, err return nil, err
@ -477,7 +479,9 @@ func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq Sele
} }
func (r sqlRepository) queryAll(ctx context.Context, sq SelectBuilder, response any, options ...model.QueryOptions) error { func (r sqlRepository) queryAll(ctx context.Context, sq SelectBuilder, response any, options ...model.QueryOptions) error {
sq = r.paginate(sq, options...) if len(options) > 0 && options[0].Offset > 0 {
sq = r.optimizePagination(sq, options[0])
}
query, args, err := r.toSQL(sq) query, args, err := r.toSQL(sq)
if err != nil { if err != nil {
return err return err
@ -508,28 +512,15 @@ func (r sqlRepository) queryAllSlice(ctx context.Context, sq SelectBuilder, resp
return err return err
} }
// paginate adds a rowid tie-breaker, so the order is total and rows with equal sort values land on the
// same page every time. Indexes already end with the rowid, so index-served sorts stay index-served.
func (r sqlRepository) paginate(sq SelectBuilder, options ...model.QueryOptions) SelectBuilder {
if len(options) == 0 || (options[0].Max == 0 && options[0].Offset == 0) {
return sq
}
order := "asc"
if strings.EqualFold(strings.TrimSpace(options[0].Order), "desc") {
order = "desc"
}
return r.optimizePagination(sq.OrderBy(r.tableName+".rowid "+order), options[0])
}
// optimizePagination uses a less inefficient pagination, by not using OFFSET. // optimizePagination uses a less inefficient pagination, by not using OFFSET.
// See https://gist.github.com/ssokolow/262503 // See https://gist.github.com/ssokolow/262503
func (r sqlRepository) optimizePagination(sq SelectBuilder, options model.QueryOptions) SelectBuilder { func (r sqlRepository) optimizePagination(sq SelectBuilder, options model.QueryOptions) SelectBuilder {
if options.Offset > conf.Server().DevOffsetOptimize { if options.Offset > conf.Server().DevOffsetOptimize {
sq = sq.RemoveOffset() sq = sq.RemoveOffset()
// Keep the outer projection, so ORDER BY names resolve to the same columns in both queries. rowidSq := sq.RemoveColumns().Columns(r.tableName + ".rowid")
rowidSq := sq.Column(r.tableName + ".rowid as _paginated_rowid").Limit(uint64(options.Offset)) rowidSq = rowidSq.Limit(uint64(options.Offset))
rowidSql, args, _ := rowidSq.ToSql() rowidSql, args, _ := rowidSq.ToSql()
sq = sq.Where(r.tableName+".rowid not in (SELECT _paginated_rowid FROM ("+rowidSql+"))", args...) sq = sq.Where(r.tableName+".rowid not in ("+rowidSql+")", args...)
} }
return sq return sq
} }

View file

@ -2,11 +2,8 @@ package persistence
import ( import (
"context" "context"
"slices"
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"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/model/request"
"github.com/navidrome/navidrome/utils/hasher" "github.com/navidrome/navidrome/utils/hasher"
@ -436,134 +433,3 @@ var _ = Describe("sqlRepository", func() {
}) })
}) })
}) })
var _ = Describe("paginated queries", func() {
var ctx context.Context
var mr model.MediaFileRepository
BeforeEach(func() {
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
mr = NewMediaFileRepository(GetDBXBuilder())
})
Describe("SQL", func() {
var r sqlRepository
BeforeEach(func() {
r = sqlRepository{tableName: "media_file", sortMappings: map[string]string{"name": "title"}}
conf.Update(func(o *conf.Options) { o.DevOffsetOptimize = 10 })
})
DescribeTable("adds a rowid tie-breaker in the requested direction",
func(options model.QueryOptions, expectedOrder string) {
sql, _, err := r.paginate(squirrel.Select("*").From("media_file"), options).ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(HaveSuffix(expectedOrder))
},
Entry("no sort", model.QueryOptions{Max: 5}, "ORDER BY media_file.rowid asc"),
Entry("desc", model.QueryOptions{Sort: "name", Order: "desc", Max: 5}, "ORDER BY media_file.rowid desc"),
)
It("leaves unpaginated queries alone", func() {
sql, _, _ := r.paginate(squirrel.Select("*").From("media_file"), model.QueryOptions{Sort: "name"}).ToSql()
Expect(sql).To(Equal("SELECT * FROM media_file"))
})
It("uses the rowid subquery only past the threshold, keeping the outer projection", func() {
sq := squirrel.Select("media_file.*", "library.path as library_path").From("media_file").OrderBy("id asc")
atThreshold, _, _ := r.paginate(sq.Limit(5).Offset(10), model.QueryOptions{Max: 5, Offset: 10}).ToSql()
Expect(atThreshold).ToNot(ContainSubstring("not in"))
past, _, _ := r.paginate(sq.Limit(5).Offset(11), model.QueryOptions{Max: 5, Offset: 11}).ToSql()
Expect(past).To(ContainSubstring("media_file.rowid not in (SELECT _paginated_rowid FROM (" +
"SELECT media_file.*, library.path as library_path, media_file.rowid as _paginated_rowid FROM media_file"))
Expect(past).ToNot(ContainSubstring("OFFSET"))
})
})
// walk pages through the repository, crossing the optimizer threshold
walk := func(ctx context.Context, sort, order string, pageSize int) []string {
var ids []string
for offset := 0; ; offset += pageSize {
page, err := mr.GetAll(ctx, model.QueryOptions{Sort: sort, Order: order, Offset: offset, Max: pageSize, Seed: "seed"})
Expect(err).ToNot(HaveOccurred(), "sort=%s order=%s offset=%d", sort, order, offset)
for _, m := range page {
ids = append(ids, m.ID)
}
if len(page) < pageSize {
return ids
}
}
}
DescribeTable("returns every song exactly once, in the same order as a single page",
func(sort string) {
conf.Update(func(o *conf.Options) { o.DevOffsetOptimize = 2 })
for _, order := range []string{"asc", "desc"} {
expected := walk(ctx, sort, order, 1000)
Expect(len(expected)).To(BeNumerically(">=", len(testSongs)))
Expect(slices.Compact(slices.Sorted(slices.Values(expected)))).To(HaveLen(len(expected)))
// Page size 2 hits offset == threshold (plain OFFSET), size 3 hits offset > threshold first.
for _, pageSize := range []int{2, 3} {
Expect(walk(ctx, sort, order, pageSize)).To(Equal(expected), "sort=%s order=%s page=%d", sort, order, pageSize)
}
}
},
Entry("id", "id"),
Entry("path", "path"),
Entry("updatedAt", "updatedAt"),
Entry("comment", "comment"),
Entry("title", "title"),
Entry("createdAt", "createdAt"),
Entry("starred (ties)", "starred"),
Entry("playCount (ties)", "playCount"),
Entry("rating (ties)", "rating"),
Entry("album", "album"),
Entry("seeded random", "random"),
)
It("keeps the library filter on every page", func() {
_, otherLib, restrictedUser := restrictedFixture("paginate")
hidden := model.MediaFile{ID: "paginate-hidden", Title: "Hidden", LibraryID: otherLib.ID, Path: p("other/hidden.mp3")}
Expect(mr.Put(ctx, &hidden)).To(Succeed())
DeferCleanup(func() { _ = mr.Delete(ctx, hidden.ID) })
conf.Update(func(o *conf.Options) { o.DevOffsetOptimize = 2 })
Expect(walk(ctx, "id", "asc", 1000)).To(ContainElement(hidden.ID))
restrictedCtx := request.WithUser(ctx, restrictedUser)
expected := walk(restrictedCtx, "id", "asc", 1000)
Expect(expected).ToNot(BeEmpty())
Expect(expected).ToNot(ContainElement(hidden.ID))
Expect(walk(restrictedCtx, "id", "asc", 3)).To(Equal(expected))
})
Describe("legacy search", func() {
BeforeEach(func() {
conf.Update(func(o *conf.Options) {
o.Search.Backend = "legacy"
o.DevOffsetOptimize = 0
})
})
It("pages albums whose name is shared with the joined library", func() {
alr := NewAlbumRepository(GetDBXBuilder())
all, err := alr.Search(ctx, "abbey", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(all).To(HaveLen(2))
second, err := alr.Search(ctx, "abbey", model.QueryOptions{Max: 1, Offset: 1})
Expect(err).ToNot(HaveOccurred())
Expect(second).To(HaveLen(1))
Expect(second[0].ID).To(Equal(all[1].ID))
})
It("pages artists", func() {
arr := NewArtistRepository(GetDBXBuilder())
all, err := arr.Search(ctx, "the", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(len(all)).To(BeNumerically(">=", 2))
second, err := arr.Search(ctx, "the", model.QueryOptions{Max: 1, Offset: 1})
Expect(err).ToNot(HaveOccurred())
Expect(second).To(HaveLen(1))
Expect(second[0].ID).To(Equal(all[1].ID))
})
})
})

View file

@ -175,7 +175,7 @@ flowchart TD
1. **Folder Traversal** 1. **Folder Traversal**
- Uses `walkDirTree` to traverse the directory structure - Uses `walkDirTree` to traverse the directory structure
- Handles symbolic links and hidden files - Handles symbolic links and hidden files
- Processes `.ndignore` files for exclusions - Processes `.ndignore` files for exclusions, and skips folders containing a `.nomedia` file when `Scanner.RespectNoMedia` is enabled
- Maps files to appropriate types (audio, image, playlist) - Maps files to appropriate types (audio, image, playlist)
2. **Metadata Extraction** 2. **Metadata Extraction**

View file

@ -7,11 +7,14 @@ import (
"path" "path"
"strings" "strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
ignore "github.com/sabhiram/go-gitignore" ignore "github.com/sabhiram/go-gitignore"
) )
const ignoreAllPattern = "**/*"
// IgnoreChecker manages .ndignore patterns using a stack-based approach. // IgnoreChecker manages .ndignore patterns using a stack-based approach.
// Use Push() to add patterns when entering a folder, Pop() when leaving, // Use Push() to add patterns when entering a folder, Pop() when leaving,
// and ShouldIgnore() to check if a path should be ignored. // and ShouldIgnore() to check if a path should be ignored.
@ -80,6 +83,24 @@ func (ic *IgnoreChecker) PushAllParents(ctx context.Context, targetPath string)
return nil return nil
} }
// PushParents pushes the patterns of each folder above target, from the root down. It returns true,
// and stops, when target or a folder above it is ignored, as a full walk would never reach target.
func (ic *IgnoreChecker) PushParents(ctx context.Context, target string) bool {
target = path.Clean(target)
if target == "." {
return false
}
dir := "."
for part := range strings.SplitSeq(target, "/") {
_ = ic.Push(ctx, dir)
dir = path.Join(dir, part)
if ic.ShouldIgnore(ctx, dir) {
return true
}
}
return false
}
// ShouldIgnore checks if the given path should be ignored based on the current patterns. // ShouldIgnore checks if the given path should be ignored based on the current patterns.
// Returns true if the path matches any ignore pattern, false otherwise. // Returns true if the path matches any ignore pattern, false otherwise.
func (ic *IgnoreChecker) ShouldIgnore(ctx context.Context, relPath string) bool { func (ic *IgnoreChecker) ShouldIgnore(ctx context.Context, relPath string) bool {
@ -95,15 +116,22 @@ func (ic *IgnoreChecker) ShouldIgnore(ctx context.Context, relPath string) bool
matches := ic.matcher.MatchesPath(relPath) matches := ic.matcher.MatchesPath(relPath)
if matches { if matches {
log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore", "path", relPath) log.Trace(ctx, "Scanner: Ignoring entry matching ignore rules", "path", relPath)
} }
return matches return matches
} }
// loadPatternsFromFolder reads the .ndignore file in the specified folder and returns the patterns. // loadPatternsFromFolder reads the .ndignore file in the specified folder and returns the patterns.
// If the file doesn't exist, returns an empty slice. // If the file doesn't exist, returns an empty slice.
// If the file exists but is empty, returns a pattern to ignore everything ("**/*"). // If the file exists but is empty, or there is a .nomedia file with Scanner.RespectNoMedia, ignores everything.
func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder string) []string { func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder string) []string {
if conf.Server().Scanner.RespectNoMedia {
if _, err := fs.Stat(ic.fsys, path.Join(folder, consts.NoMediaFile)); err == nil {
log.Trace(ctx, "Scanner: .nomedia file found, ignoring everything", "path", folder)
return []string{ignoreAllPattern}
}
}
ignoreFilePath := path.Join(folder, consts.ScanIgnoreFile) ignoreFilePath := path.Join(folder, consts.ScanIgnoreFile)
var patterns []string var patterns []string
@ -138,7 +166,7 @@ func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder stri
// If the .ndignore file is empty, ignore everything // If the .ndignore file is empty, ignore everything
if len(patterns) == 0 { if len(patterns) == 0 {
log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", folder) log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", folder)
patterns = []string{"**/*"} patterns = []string{ignoreAllPattern}
} }
return patterns return patterns

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"testing/fstest" "testing/fstest"
"github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@ -37,6 +38,26 @@ var _ = Describe("IgnoreChecker", func() {
}) })
}) })
DescribeTable("when .nomedia file exists",
func(respectNoMedia bool, files fstest.MapFS, expected []string) {
conf.Update(func(c *conf.Options) { c.Scanner.RespectNoMedia = respectNoMedia })
ic = newIgnoreChecker(files)
patterns := ic.loadPatternsFromFolder(ctx, ".")
Expect(patterns).To(Equal(expected))
},
Entry("empty", true, fstest.MapFS{".nomedia": &fstest.MapFile{}}, []string{"**/*"}),
Entry("with content", true, fstest.MapFS{".nomedia": &fstest.MapFile{Data: []byte("*.txt")}}, []string{"**/*"}),
Entry("alongside .ndignore", true, fstest.MapFS{
".nomedia": &fstest.MapFile{},
".ndignore": &fstest.MapFile{Data: []byte("*.txt")},
}, []string{"**/*"}),
Entry("disabled", false, fstest.MapFS{".nomedia": &fstest.MapFile{}}, []string(nil)),
Entry("disabled, alongside .ndignore", false, fstest.MapFS{
".nomedia": &fstest.MapFile{},
".ndignore": &fstest.MapFile{Data: []byte("*.txt")},
}, []string{"*.txt"}),
)
DescribeTable("parsing .ndignore content", DescribeTable("parsing .ndignore content",
func(content string, expectedPatterns []string) { func(content string, expectedPatterns []string) {
fsys := fstest.MapFS{ fsys := fstest.MapFS{

View file

@ -44,7 +44,8 @@ var _ = Describe("runPhase", func() {
) )
BeforeEach(func() { BeforeEach(func() {
ctx = context.Background() ctx = GinkgoT().Context()
sum.Store(0)
phaseNum = 1 phaseNum = 1
phase = &mockPhase{ phase = &mockPhase{
num: 3, num: 3,

View file

@ -3,6 +3,7 @@ package scanner_test
import ( import (
"context" "context"
"errors" "errors"
"os"
"path/filepath" "path/filepath"
"testing/fstest" "testing/fstest"
"time" "time"
@ -41,9 +42,13 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
} }
BeforeAll(func() { BeforeAll(func() {
tests.SkipOnWindows("SQLite file lock blocks TempDir cleanup (#TBD-path-sep-scanner)")
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
tmpDir := GinkgoT().TempDir() // db.Db() is a process-wide singleton, so this dir may hold the open DB until
// the suite ends, and Windows can't delete an open file
tmpDir, err := os.MkdirTemp("", "scanner-test")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _ = os.RemoveAll(tmpDir) })
DeferCleanup(conf.SnapshotConfig())
conf.Update(func(c *conf.Options) { conf.Update(func(c *conf.Options) {
c.DbPath = filepath.Join(tmpDir, "test-scanner-multilibrary.db?_journal_mode=WAL") c.DbPath = filepath.Join(tmpDir, "test-scanner-multilibrary.db?_journal_mode=WAL")
}) })

View file

@ -2,6 +2,7 @@ package scanner_test
import ( import (
"context" "context"
"os"
"path/filepath" "path/filepath"
"testing/fstest" "testing/fstest"
"time" "time"
@ -34,9 +35,13 @@ var _ = Describe("ScanFolders", Ordered, func() {
var fsys storagetest.FakeFS var fsys storagetest.FakeFS
BeforeAll(func() { BeforeAll(func() {
tests.SkipOnWindows("SQLite file lock blocks TempDir cleanup (#TBD-path-sep-scanner)")
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
tmpDir := GinkgoT().TempDir() // db.Db() is a process-wide singleton, so this dir may hold the open DB until
// the suite ends, and Windows can't delete an open file
tmpDir, err := os.MkdirTemp("", "scanner-test")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _ = os.RemoveAll(tmpDir) })
DeferCleanup(conf.SnapshotConfig())
conf.Update(func(c *conf.Options) { c.DbPath = filepath.Join(tmpDir, "test-selective-scan.db?_journal_mode=WAL") }) conf.Update(func(c *conf.Options) { c.DbPath = filepath.Join(tmpDir, "test-selective-scan.db?_journal_mode=WAL") })
log.Warn("Using DB at " + conf.Server().DbPath) log.Warn("Using DB at " + conf.Server().DbPath)
db.Db().SetMaxOpenConns(1) db.Db().SetMaxOpenConns(1)

View file

@ -59,6 +59,7 @@ var _ = Describe("Scanner", Ordered, func() {
tmpDir, err := os.MkdirTemp("", "scanner-test") tmpDir, err := os.MkdirTemp("", "scanner-test")
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _ = os.RemoveAll(tmpDir) }) DeferCleanup(func() { _ = os.RemoveAll(tmpDir) })
DeferCleanup(conf.SnapshotConfig())
conf.Update(func(c *conf.Options) { c.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") }) conf.Update(func(c *conf.Options) { c.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") })
log.Warn("Using DB at " + conf.Server().DbPath) log.Warn("Using DB at " + conf.Server().DbPath)
//conf.Server.DbPath = ":memory:" //conf.Server.DbPath = ":memory:"

View file

@ -50,11 +50,10 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-
continue continue
} }
// Create checker and push patterns from root to this folder // walkFolder pushes the target folder's own patterns, so only its parents are pushed here
checker := newIgnoreChecker(job.fs) checker := newIgnoreChecker(job.fs)
err = checker.PushAllParents(ctx, folderPath) if checker.PushParents(ctx, folderPath) {
if err != nil { log.Debug(ctx, "Scanner: Skipping target folder ignored by its parents", "path", folderPath)
log.Error(ctx, "Scanner: Error pushing ignore patterns for target folder", "path", folderPath, err)
continue continue
} }

View file

@ -27,8 +27,26 @@ var _ = Describe("walk_dir_tree", func() {
ctx context.Context ctx context.Context
) )
// Helper function to call walkDirTree and collect folders from the results channel
getFolders := func(targets ...string) map[string]*folderEntry {
results, err := walkDirTree(ctx, job, targets...)
Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{}
g := errgroup.Group{}
g.Go(func() error {
for folder := range results {
folders[folder.path] = folder
}
return nil
})
_ = g.Wait()
return folders
}
Context("full library", func() { Context("full library", func() {
BeforeEach(func() { BeforeEach(func() {
conf.Update(func(c *conf.Options) { c.Scanner.RespectNoMedia = true })
ctx = GinkgoT().Context() ctx = GinkgoT().Context()
fsys = &mockMusicFS{ fsys = &mockMusicFS{
FS: fstest.MapFS{ FS: fstest.MapFS{
@ -53,6 +71,9 @@ var _ = Describe("walk_dir_tree", func() {
"root/h/.hidden.mp3": {}, "root/h/.hidden.mp3": {},
"root/i/.git/config": {}, "root/i/.git/config": {},
"root/i/.streams/stream.mp3": {}, "root/i/.streams/stream.mp3": {},
"root/j/.nomedia": {},
"root/j/f1.mp3": {},
"root/j/sub/f2.mp3": {},
}, },
} }
job = &scanJob{ job = &scanJob{
@ -61,23 +82,6 @@ var _ = Describe("walk_dir_tree", func() {
} }
}) })
// Helper function to call walkDirTree and collect folders from the results channel
getFolders := func() map[string]*folderEntry {
results, err := walkDirTree(ctx, job)
Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{}
g := errgroup.Group{}
g.Go(func() error {
for folder := range results {
folders[folder.path] = folder
}
return nil
})
_ = g.Wait()
return folders
}
DescribeTable("symlink handling", DescribeTable("symlink handling",
func(followSymlinks bool, expectedFolderCount int) { func(followSymlinks bool, expectedFolderCount int) {
conf.Update(func(c *conf.Options) { c.Scanner.FollowSymlinks = followSymlinks }) conf.Update(func(c *conf.Options) { c.Scanner.FollowSymlinks = followSymlinks })
@ -100,6 +104,8 @@ var _ = Describe("walk_dir_tree", func() {
Expect(folders["root/c"].audioFiles).To(BeEmpty()) Expect(folders["root/c"].audioFiles).To(BeEmpty())
Expect(folders["root/c"].imageFiles).To(BeEmpty()) Expect(folders["root/c"].imageFiles).To(BeEmpty())
Expect(folders).ToNot(HaveKey("root/d")) Expect(folders).ToNot(HaveKey("root/d"))
Expect(folders["root/j"].audioFiles).To(BeEmpty())
Expect(folders).ToNot(HaveKey("root/j/sub"))
// By default (Scanner.IgnoreDotFolders == true), dot-prefixed // By default (Scanner.IgnoreDotFolders == true), dot-prefixed
// folders are skipped, dot-prefixed files are not indexed, and // folders are skipped, dot-prefixed files are not indexed, and
@ -122,8 +128,8 @@ var _ = Describe("walk_dir_tree", func() {
Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3"))
} }
}, },
Entry("with symlinks enabled", true, 11), Entry("with symlinks enabled", true, 12),
Entry("with symlinks disabled", false, 10), Entry("with symlinks disabled", false, 11),
) )
DescribeTable("dot-prefixed folders with IgnoreDotFolders disabled", DescribeTable("dot-prefixed folders with IgnoreDotFolders disabled",
@ -150,6 +156,37 @@ var _ = Describe("walk_dir_tree", func() {
Entry("with symlinks enabled", true), Entry("with symlinks enabled", true),
Entry("with symlinks disabled", false), Entry("with symlinks disabled", false),
) )
It("scans folders containing a .nomedia file when RespectNoMedia is disabled", func() {
conf.Update(func(c *conf.Options) { c.Scanner.RespectNoMedia = false })
folders := getFolders()
Expect(folders["root/j"].audioFiles).To(HaveKey("f1.mp3"))
Expect(folders["root/j/sub"].audioFiles).To(HaveKey("f2.mp3"))
})
})
Context("targeted scans", func() {
BeforeEach(func() {
conf.Update(func(c *conf.Options) { c.Scanner.RespectNoMedia = true })
ctx = GinkgoT().Context()
})
DescribeTable("skips a folder ignored by its parent, even if it re-includes files",
func(targets []string) {
job = &scanJob{fs: &mockMusicFS{FS: fstest.MapFS{
"a/.nomedia": {},
"a/b/.ndignore": {Data: []byte("!song.mp3\n!c")},
"a/b/song.mp3": {},
"a/b/c/song.mp3": {},
}}, lib: model.Library{Path: "/music"}}
Expect(getFolders(targets...)).ToNot(SatisfyAny(HaveKey("a/b"), HaveKey("a/b/c")))
},
Entry("full scan", nil),
Entry("targeted scan", []string{"a/b"}),
Entry("targeted scan of a deeper folder", []string{"a/b/c"}),
)
}) })
Context("with target folders", func() { Context("with target folders", func() {

View file

@ -432,6 +432,18 @@ var _ = Describe("Watcher", func() {
}) })
}) })
DescribeTable("with a .nomedia file in a parent folder",
func(respectNoMedia bool, expected bool) {
conf.Update(func(c *conf.Options) { c.Scanner.RespectNoMedia = respectNoMedia })
fsys := &mockMusicFS{FS: fstest.MapFS{
"rock/.nomedia": &fstest.MapFile{},
"rock/album/song.mp3": &fstest.MapFile{},
}}
Expect(w.shouldIgnoreFolderPath(ctx, fsys, "rock/album")).To(Equal(expected))
},
Entry("ignores the folder when RespectNoMedia is enabled", true, true),
Entry("scans the folder when RespectNoMedia is disabled", false, false),
)
}) })
}) })

View file

@ -14,31 +14,23 @@ import (
"github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/id"
) )
func initialSetup(ds model.DataStore) { // Keeps reads out of the transaction: SQLite fails a write that follows a read at once, without waiting for the lock
ctx := context.TODO() func initialSetup(ctx context.Context, ds model.DataStore) error {
err := ds.WithTx(func(tx model.DataStore) error { if err := ds.Library().StoreMusicFolder(ctx); err != nil {
if err := tx.Library().StoreMusicFolder(ctx); err != nil { return err
return err }
} if _, err := ds.Property().Get(ctx, consts.InitialSetupFlagKey); err == nil {
return nil
properties := tx.Property() }
_, err := properties.Get(ctx, consts.InitialSetupFlagKey) return ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
if err == nil {
return nil
}
log.Info("Running initial setup") log.Info("Running initial setup")
if conf.Server().DevAutoCreateAdminPassword != "" { if conf.Server().DevAutoCreateAdminPassword != "" {
if err = createInitialAdminUser(ctx, tx, conf.Server().DevAutoCreateAdminPassword); err != nil { if err := createInitialAdminUser(ctx, tx, conf.Server().DevAutoCreateAdminPassword); err != nil {
return err return err
} }
} }
return tx.Property().Put(ctx, consts.InitialSetupFlagKey, time.Now().String())
err = properties.Put(ctx, consts.InitialSetupFlagKey, time.Now().String())
return err
}, "initial setup") }, "initial setup")
if err != nil {
log.Fatal("Error running initial setup", err)
}
} }
// If the Dev Admin user is not present, create it // If the Dev Admin user is not present, create it

View file

@ -3,8 +3,14 @@ package server
import ( import (
"context" "context"
"errors" "errors"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
@ -59,3 +65,61 @@ var _ = Describe("initial_setup", func() {
}) })
}) })
}) })
// db.Db() is a process-wide singleton that the BeforeAll cleanup closes; keep these the only real-DB specs in this package.
var _ = Describe("initialSetup with a real database", Ordered, func() {
var ctx context.Context
var ds model.DataStore
holdWriteLock := func(releaseAfter time.Duration) {
conn, err := db.Db().Conn(ctx)
Expect(err).ToNot(HaveOccurred())
_, err = conn.ExecContext(ctx, "BEGIN IMMEDIATE")
Expect(err).ToNot(HaveOccurred())
released := make(chan struct{})
go func() {
defer close(released)
time.Sleep(releaseAfter)
_, _ = conn.ExecContext(context.Background(), "ROLLBACK")
_ = conn.Close()
}()
DeferCleanup(func() { <-released })
}
BeforeAll(func() {
DeferCleanup(conf.SnapshotConfig())
conf.Update(func(c *conf.Options) {
c.DbPath = filepath.Join(GinkgoT().TempDir(), "initial-setup.db") + "?_journal_mode=WAL&_busy_timeout=1000"
})
DeferCleanup(db.Init(GinkgoT().Context()))
ds = persistence.New(db.Db())
})
BeforeEach(func() {
ctx = GinkgoT().Context()
})
It("completes the first-run setup when the write lock is held briefly", func() {
holdWriteLock(300 * time.Millisecond)
Expect(initialSetup(ctx, ds)).To(Succeed())
Expect(ds.Property().Get(ctx, consts.InitialSetupFlagKey)).ToNot(BeEmpty())
})
It("does not need the write lock when setup already ran and the music folder is unchanged", func() {
holdWriteLock(1500 * time.Millisecond)
Expect(initialSetup(ctx, ds)).To(Succeed())
})
It("stores a changed music folder when the write lock is held briefly", func() {
newFolder := conf.Server().MusicFolder + "-moved"
conf.Update(func(c *conf.Options) { c.MusicFolder = newFolder })
holdWriteLock(300 * time.Millisecond)
Expect(initialSetup(ctx, ds)).To(Succeed())
lib, err := ds.Library().Get(ctx, model.DefaultLibraryID)
Expect(err).ToNot(HaveOccurred())
Expect(lib.Path).To(Equal(newFolder))
})
})

View file

@ -37,7 +37,9 @@ type Server struct {
func New(ds model.DataStore, broker events.Broker, insights metrics.Insights) *Server { func New(ds model.DataStore, broker events.Broker, insights metrics.Insights) *Server {
s := &Server{ds: ds, broker: broker, insights: insights} s := &Server{ds: ds, broker: broker, insights: insights}
initialSetup(ds) if err := initialSetup(context.TODO(), ds); err != nil {
log.Fatal("Error running initial setup", err)
}
auth.Init(s.ds) auth.Init(s.ds)
s.initRoutes() s.initRoutes()
s.mountAuthenticationRoutes() s.mountAuthenticationRoutes()

View file

@ -1,7 +1,8 @@
import React, { useState, useCallback } from 'react' import React, { useState, useCallback, useEffect } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Field, Form } from 'react-final-form' import { Field, Form } from 'react-final-form'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
import { useHistory } from 'react-router-dom'
import Button from '@material-ui/core/Button' import Button from '@material-ui/core/Button'
import Card from '@material-ui/core/Card' import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions' import CardActions from '@material-ui/core/CardActions'
@ -11,6 +12,7 @@ import TextField from '@material-ui/core/TextField'
import { ThemeProvider, makeStyles } from '@material-ui/core/styles' import { ThemeProvider, makeStyles } from '@material-ui/core/styles'
import { import {
createMuiTheme, createMuiTheme,
useCheckAuth,
useLogin, useLogin,
useNotify, useNotify,
useTranslate, useTranslate,
@ -322,6 +324,16 @@ const Login = ({ location }) => {
const notify = useNotify() const notify = useNotify()
const login = useLogin() const login = useLogin()
const dispatch = useDispatch() const dispatch = useDispatch()
const checkAuth = useCheckAuth()
const history = useHistory()
// Signed-in users opening #/login go straight to the app;
// replace keeps Back from bouncing.
useEffect(() => {
checkAuth({}, false)
.then(() => history.replace('/'))
.catch(() => {})
}, [checkAuth, history])
const handleSubmit = useCallback( const handleSubmit = useCallback(
(auth) => { (auth) => {

View file

@ -0,0 +1,70 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, beforeEach, vi } from 'vitest'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import { createMuiTheme } from '@material-ui/core/styles'
import Login from './Login'
const mocks = vi.hoisted(() => ({
checkAuth: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
}))
vi.mock('react-admin', () => ({
createMuiTheme: (theme) => theme,
useCheckAuth: () => mocks.checkAuth,
useLogin: () => vi.fn(() => Promise.resolve()),
useNotify: () => vi.fn(),
useTranslate: () => (x) => x,
useVersion: () => 1,
}))
vi.mock('react-router-dom', () => ({
useHistory: () => ({ push: mocks.push, replace: mocks.replace }),
}))
vi.mock('../themes/useCurrentTheme', () => ({
default: () => createMuiTheme({}),
}))
vi.mock('./Notification', () => ({
default: () => <div />,
}))
const renderLogin = () =>
render(
<Provider store={createStore(() => ({}))}>
<Login location={{}} />
</Provider>,
)
describe('<Login />', () => {
beforeEach(() => {
mocks.checkAuth.mockReset()
mocks.push.mockReset()
mocks.replace.mockReset()
})
it('replaces the login page with the home page when already signed in', async () => {
mocks.checkAuth.mockResolvedValue()
renderLogin()
await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith('/'))
expect(mocks.checkAuth).toHaveBeenCalledWith({}, false)
expect(mocks.push).not.toHaveBeenCalled()
})
it('shows the login form when the user is not signed in', async () => {
mocks.checkAuth.mockRejectedValue()
renderLogin()
await waitFor(() => expect(mocks.checkAuth).toHaveBeenCalledWith({}, false))
expect(screen.getByText('ra.auth.sign_in')).toBeInTheDocument()
expect(mocks.replace).not.toHaveBeenCalled()
expect(mocks.push).not.toHaveBeenCalled()
})
})