mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-14 22:28:41 +01:00
Compare commits
5 commits
fix/6285-l
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce7ef53ca |
||
|
|
88adc02f74 |
||
|
|
3a40f29aa5 |
||
|
|
55e52b7ce4 |
||
|
|
6177c92e32 |
22 changed files with 334 additions and 209 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -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**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
70
ui/src/layout/Login.test.jsx
Normal file
70
ui/src/layout/Login.test.jsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue