From 73ec89e1afb762437df4ffef704330f789132620 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 12 Nov 2025 13:01:11 -0500 Subject: [PATCH 01/42] feat(ui): add SizeField to display total size in LibraryList Signed-off-by: Deluan --- ui/src/library/LibraryList.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx index c2d2f6295..932732b10 100644 --- a/ui/src/library/LibraryList.jsx +++ b/ui/src/library/LibraryList.jsx @@ -9,7 +9,7 @@ import { BooleanField, } from 'react-admin' import { useMediaQuery } from '@material-ui/core' -import { List, DateField, useResourceRefresh } from '../common' +import { List, DateField, useResourceRefresh, SizeField } from '../common' const LibraryFilter = (props) => ( @@ -42,6 +42,7 @@ const LibraryList = (props) => { + Date: Wed, 12 Nov 2025 13:11:33 -0500 Subject: [PATCH 02/42] refactor(scanner): refactor legacyReleaseDate logic and add tests for date mapping Signed-off-by: Deluan --- model/metadata/legacy_ids.go | 8 +------- model/metadata/legacy_ids_test.go | 30 ---------------------------- model/metadata/map_mediafile_test.go | 17 ++++++++++++++++ 3 files changed, 18 insertions(+), 37 deletions(-) delete mode 100644 model/metadata/legacy_ids_test.go diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go index 0a3bf0bf3..18a273550 100644 --- a/model/metadata/legacy_ids.go +++ b/model/metadata/legacy_ids.go @@ -23,7 +23,7 @@ func legacyTrackID(mf model.MediaFile, prependLibId bool) string { } func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string { - releaseDate := legacyReleaseDate(md) + _, _, releaseDate := md.mapDates() albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md))) if !conf.Server.Scanner.GroupAlbumReleases { if len(releaseDate) != 0 { @@ -55,9 +55,3 @@ func legacyMapAlbumName(md Metadata) string { consts.UnknownAlbum, ) } - -// Keep the TaggedLikePicard logic for backwards compatibility -func legacyReleaseDate(md Metadata) string { - _, _, releaseDate := md.mapDates() - return string(releaseDate) -} diff --git a/model/metadata/legacy_ids_test.go b/model/metadata/legacy_ids_test.go deleted file mode 100644 index b6d096763..000000000 --- a/model/metadata/legacy_ids_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package metadata - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("legacyReleaseDate", func() { - - DescribeTable("legacyReleaseDate", - func(recordingDate, originalDate, releaseDate, expected string) { - md := New("", Info{ - Tags: map[string][]string{ - "DATE": {recordingDate}, - "ORIGINALDATE": {originalDate}, - "RELEASEDATE": {releaseDate}, - }, - }) - - result := legacyReleaseDate(md) - Expect(result).To(Equal(expected)) - }, - Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"), - Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"), - Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"), - Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"), - Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"), - Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"), - ) -}) diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index ddda39bc2..e3adf3fae 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -75,6 +75,23 @@ var _ = Describe("ToMediaFile", func() { Expect(mf.OriginalYear).To(Equal(1966)) Expect(mf.ReleaseYear).To(Equal(2014)) }) + DescribeTable("legacyReleaseDate (TaggedLikePicard old behavior)", + func(recordingDate, originalDate, releaseDate, expected string) { + mf := toMediaFile(model.RawTags{ + "DATE": {recordingDate}, + "ORIGINALDATE": {originalDate}, + "RELEASEDATE": {releaseDate}, + }) + + Expect(mf.ReleaseDate).To(Equal(expected)) + }, + Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"), + Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"), + Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"), + Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"), + Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"), + Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"), + ) }) Describe("Lyrics", func() { From c3e8c67116ac71d7eb479cb5a3e8beff095ef80e Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 12 Nov 2025 13:23:18 -0500 Subject: [PATCH 03/42] feat(ui): update totalSize formatting to display two decimal places Signed-off-by: Deluan --- ui/src/library/LibraryEdit.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx index 3d981b076..7e89c892c 100644 --- a/ui/src/library/LibraryEdit.jsx +++ b/ui/src/library/LibraryEdit.jsx @@ -169,7 +169,7 @@ const LibraryEdit = (props) => { resource={'library'} source={'totalSize'} label={translate('resources.library.fields.totalSize')} - format={formatBytes} + format={(v) => formatBytes(v, 2)} fullWidth variant="outlined" /> From f939ad84f308692134206e4226d23bf401720635 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 12 Nov 2025 16:17:41 -0500 Subject: [PATCH 04/42] fix(ui): increase contrast of button text in the Dark theme Signed-off-by: Deluan --- ui/src/themes/dark.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/src/themes/dark.js b/ui/src/themes/dark.js index 2f06b4337..15d8aa365 100644 --- a/ui/src/themes/dark.js +++ b/ui/src/themes/dark.js @@ -16,6 +16,11 @@ export default { color: 'white', }, }, + MuiButton: { + textPrimary: { + color: '#fff', + }, + }, NDLogin: { systemNameLink: { color: '#0085ff', From 9b3bdc8a8b6c3cb11e96ff04c7c75f904ebc1da1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 13 Nov 2025 18:05:00 -0500 Subject: [PATCH 05/42] fix(ui): adjust margins for bulk actions buttons in Spotify-ish and Ligera Signed-off-by: Deluan --- ui/src/themes/ligera.js | 5 +++++ ui/src/themes/spotify.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/ui/src/themes/ligera.js b/ui/src/themes/ligera.js index 97dda93ab..363a379bc 100644 --- a/ui/src/themes/ligera.js +++ b/ui/src/themes/ligera.js @@ -448,6 +448,11 @@ export default { backgroundColor: bLight['500'], }, }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, RaPaginationActions: { button: { backgroundColor: '#fff', diff --git a/ui/src/themes/spotify.js b/ui/src/themes/spotify.js index c40ed20aa..725831cc7 100644 --- a/ui/src/themes/spotify.js +++ b/ui/src/themes/spotify.js @@ -389,6 +389,11 @@ export default { marginRight: '1rem', }, }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, RaPaginationActions: { currentPageButton: { border: '1px solid #b3b3b3', From 2385c8a548f6d71e8b1acba503ae0161a9ddcc1e Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 13 Nov 2025 18:46:06 -0500 Subject: [PATCH 06/42] test: mock formatFullDate for consistent test results --- ui/src/album/AlbumDetails.test.jsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ui/src/album/AlbumDetails.test.jsx b/ui/src/album/AlbumDetails.test.jsx index e03022677..484045444 100644 --- a/ui/src/album/AlbumDetails.test.jsx +++ b/ui/src/album/AlbumDetails.test.jsx @@ -14,6 +14,24 @@ vi.mock('@material-ui/core', async () => { } }) +// Mock formatFullDate to return deterministic results +vi.mock('../utils', async () => { + const actual = await import('../utils') + return { + ...actual, + formatFullDate: (date) => { + if (!date) return '' + // Use en-CA locale for consistent test results + return new Date(date).toLocaleDateString('en-CA', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }) + }, + } +}) + describe('Details component', () => { describe('Desktop view', () => { beforeEach(() => { From a10f839221db06ee1dbec8585bd75d869ed46176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 14 Nov 2025 12:19:10 -0500 Subject: [PATCH 07/42] fix(server): prefer cover.jpg over cover.1.jpg (#4684) * fix(reader): prioritize cover art selection by base filename without numeric suffixes Signed-off-by: Deluan * fix(reader): update image file comparison to use natural sorting and prioritize files without numeric suffixes Signed-off-by: Deluan * refactor(reader): simplify comparison, add case-sensitivity test case Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/artwork/reader_album.go | 28 ++++++++- core/artwork/reader_album_test.go | 94 +++++++++++++++++++++++-------- go.mod | 1 + go.sum | 4 +- 4 files changed, 98 insertions(+), 29 deletions(-) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 55d8b4352..cb4db97fe 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -1,6 +1,7 @@ package artwork import ( + "cmp" "context" "crypto/md5" "fmt" @@ -11,6 +12,7 @@ import ( "time" "github.com/Masterminds/squirrel" + "github.com/maruel/natural" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/external" @@ -116,8 +118,30 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo } // Sort image files to ensure consistent selection of cover art - // This prioritizes files from lower-numbered disc folders by sorting the paths - slices.Sort(imgFiles) + // This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg) + // by comparing base filenames without extensions + slices.SortFunc(imgFiles, compareImageFiles) return paths, imgFiles, &updatedAt, nil } + +// compareImageFiles compares two image file paths for sorting. +// It extracts the base filename (without extension) and compares case-insensitively. +// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1". +// Note: This function is called O(n log n) times during sorting, but in practice albums +// typically have only 1-20 image files, making the repeated string operations negligible. +func compareImageFiles(a, b string) int { + // Case-insensitive comparison + a = strings.ToLower(a) + b = strings.ToLower(b) + + // Extract base filenames without extensions + baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a)) + baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b)) + + // Compare base names first, then full paths if equal + return cmp.Or( + natural.Compare(baseA, baseB), + natural.Compare(a, b), + ) +} diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index 2665632b9..fd5f8a2be 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -27,26 +27,7 @@ var _ = Describe("Album Artwork Reader", func() { expectedAt = now.Add(5 * time.Minute) // Set up the test folders with image files - repo = &fakeFolderRepo{ - result: []model.Folder{ - { - Path: "Artist/Album/Disc1", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg", "back.jpg"}, - }, - { - Path: "Artist/Album/Disc2", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - Path: "Artist/Album/Disc10", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - }, - err: nil, - } + repo = &fakeFolderRepo{} ds = &fakeDataStore{ folderRepo: repo, } @@ -58,19 +39,82 @@ var _ = Describe("Album Artwork Reader", func() { }) It("returns sorted image files", func() { + repo.result = []model.Folder{ + { + Path: "Artist/Album/Disc1", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"}, + }, + { + Path: "Artist/Album/Disc2", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + Path: "Artist/Album/Disc10", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + } + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) Expect(err).ToNot(HaveOccurred()) Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - // Check that image files are sorted alphabetically - Expect(imgFiles).To(HaveLen(4)) + // Check that image files are sorted by base name (without extension) + Expect(imgFiles).To(HaveLen(5)) - // The files should be sorted by full path + // Files should be sorted by base filename without extension, then by full path + // "back" < "cover", so back.jpg comes first + // Then all cover.jpg files, sorted by path Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg"))) Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg"))) - Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg"))) + Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg"))) + Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg"))) + Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg"))) + }) + + It("prioritizes files without numeric suffixes", func() { + // Test case for issue #4683: cover.jpg should come before cover.1.jpg + repo.result = []model.Folder{ + { + Path: "Artist/Album", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(3)) + + // cover.jpg should come first because "cover" < "cover.1" < "cover.2" + Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg"))) + Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg"))) + }) + + It("handles case-insensitive sorting", func() { + // Test that Cover.jpg and cover.jpg are treated as equivalent + repo.result = []model.Folder{ + { + Path: "Artist/Album", + ImagesUpdatedAt: now, + ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(3)) + + // Files should be sorted case-insensitively: BACK, cover, Folder + Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg"))) + Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg"))) }) }) }) diff --git a/go.mod b/go.mod index dcc77d063..5a6a99070 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/knqyf263/go-plugin v0.9.0 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v2 v2.1.6 + github.com/maruel/natural v1.2.1 github.com/matoous/go-nanoid/v2 v2.1.0 github.com/mattn/go-sqlite3 v1.14.32 github.com/microcosm-cc/bluemonday v1.0.27 diff --git a/go.sum b/go.sum index 10feea900..7cda0ce8d 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,8 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= -github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/maruel/natural v1.2.1 h1:G/y4pwtTA07lbQsMefvsmEO0VN0NfqpxprxXDM4R/4o= +github.com/maruel/natural v1.2.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= From bca76069c314b21fbc8c6226514b622b851e5f3b Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 14 Nov 2025 13:15:50 -0500 Subject: [PATCH 08/42] fix(server): prioritize artist base image filenames over numeric suffixes and add tests for sorting Signed-off-by: Deluan --- core/artwork/reader_artist.go | 14 +++++- core/artwork/reader_artist_test.go | 73 ++++++++++++++++++++++++++---- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index cb029a16e..da8141a2d 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "slices" "strings" "time" @@ -139,11 +140,22 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos return nil, "", err } + // Filter to valid image files + var imagePaths []string for _, m := range matches { if !model.IsImageFile(m) { continue } - filePath := filepath.Join(folder, m) + imagePaths = append(imagePaths, m) + } + + // Sort image files by prioritizing base filenames without numeric + // suffixes (e.g., artist.jpg before artist.1.jpg) + slices.SortFunc(imagePaths, compareImageFiles) + + // Try to open files in sorted order + for _, p := range imagePaths { + filePath := filepath.Join(folder, p) f, err := os.Open(filePath) if err != nil { log.Warn(ctx, "Could not open cover art file", "file", filePath, err) diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 527b0849f..e6a0168f8 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -240,24 +240,79 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) // Create multiple matching files - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.txt"), []byte("text file"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) testFunc = fromArtistFolder(ctx, artistDir, "artist.*") }) - It("returns the first valid image file", func() { + It("returns the first valid image file in sorted order", func() { reader, path, err := testFunc() Expect(err).ToNot(HaveOccurred()) Expect(reader).ToNot(BeNil()) - // Should return an image file, not the text file - Expect(path).To(SatisfyAny( - ContainSubstring("artist.jpg"), - ContainSubstring("artist.png"), - )) - Expect(path).ToNot(ContainSubstring("artist.txt")) + // Should return an image file, + // Files are sorted: jpg comes before png alphabetically. + // .abc comes first, but it's not an image. + Expect(path).To(ContainSubstring("artist.jpg")) + reader.Close() + }) + }) + + When("prioritizing files without numeric suffixes", func() { + BeforeEach(func() { + // Test case for issue #4683: artist.jpg should come before artist.1.jpg + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create multiple matches with and without numeric suffixes + Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist.jpg")) + + // Verify it's the main file, not a numbered variant + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("artist main")) + reader.Close() + }) + }) + + When("handling case-insensitive sorting", func() { + BeforeEach(func() { + // Test case to ensure case-insensitive natural sorting + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create files with mixed case names + Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "*.*") + }) + + It("sorts case-insensitively", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + + // Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder") + Expect(path).To(ContainSubstring("artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("artist")) reader.Close() }) }) From 28d5299ffc02498a63a8d1618a0d376631ef1f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 14 Nov 2025 22:15:43 -0500 Subject: [PATCH 09/42] feat(scanner): implement selective folder scanning and file system watcher improvements (#4674) * feat: Add selective folder scanning capability Implement targeted scanning of specific library/folder pairs without full recursion. This enables efficient rescanning of individual folders when changes are detected, significantly reducing scan time for large libraries. Key changes: - Add ScanTarget struct and ScanFolders API to Scanner interface - Implement CLI flag --targets for specifying libraryID:folderPath pairs - Add FolderRepository.GetByPaths() for batch folder info retrieval - Create loadSpecificFolders() for non-recursive directory loading - Scope GC operations to affected libraries only (with TODO for full impl) - Add comprehensive tests for selective scanning behavior The selective scan: - Only processes specified folders (no subdirectory recursion) - Maintains library isolation - Runs full maintenance pipeline scoped to affected libraries - Supports both full and quick scan modes Examples: navidrome scan --targets "1:Music/Rock,1:Music/Jazz" navidrome scan --full --targets "2:Classical" * feat(folder): replace GetByPaths with GetFolderUpdateInfo for improved folder updates retrieval Signed-off-by: Deluan * test: update parseTargets test to handle folder names with spaces Signed-off-by: Deluan * refactor(folder): remove unused LibraryPath struct and update GC logging message Signed-off-by: Deluan * refactor(folder): enhance external scanner to support target-specific scanning Signed-off-by: Deluan * refactor(scanner): simplify scanner methods Signed-off-by: Deluan * feat(watcher): implement folder scanning notifications with deduplication Signed-off-by: Deluan * refactor(watcher): add resolveFolderPath function for testability Signed-off-by: Deluan * feat(watcher): implement path ignoring based on .ndignore patterns Signed-off-by: Deluan * refactor(scanner): implement IgnoreChecker for managing .ndignore patterns Signed-off-by: Deluan * refactor(ignore_checker): rename scanner to lineScanner for clarity Signed-off-by: Deluan * refactor(scanner): enhance ScanTarget struct with String method for better target representation Signed-off-by: Deluan * fix(scanner): validate library ID to prevent negative values Signed-off-by: Deluan * refactor(scanner): simplify GC method by removing library ID parameter Signed-off-by: Deluan * feat(scanner): update folder scanning to include all descendants of specified folders Signed-off-by: Deluan * feat(subsonic): allow selective scan in the /startScan endpoint Signed-off-by: Deluan * refactor(scanner): update CallScan to handle specific library/folder pairs Signed-off-by: Deluan * refactor(scanner): streamline scanning logic by removing scanAll method Signed-off-by: Deluan * test: enhance mockScanner for thread safety and improve test reliability Signed-off-by: Deluan * refactor(scanner): move scanner.ScanTarget to model.ScanTarget Signed-off-by: Deluan * refactor: move scanner types to model,implement MockScanner Signed-off-by: Deluan * refactor(scanner): update scanner interface and implementations to use model.Scanner Signed-off-by: Deluan * refactor(folder_repository): normalize target path handling by using filepath.Clean Signed-off-by: Deluan * test(folder_repository): add comprehensive tests for folder retrieval and child exclusion Signed-off-by: Deluan * refactor(scanner): simplify selective scan logic using slice.Filter Signed-off-by: Deluan * refactor(scanner): streamline phase folder and album creation by removing unnecessary library parameter Signed-off-by: Deluan * refactor(scanner): move initialization logic from phase_1 to the scanner itself Signed-off-by: Deluan * refactor(tests): rename selective scan test file to scanner_selective_test.go Signed-off-by: Deluan * feat(configuration): add DevSelectiveWatcher configuration option Signed-off-by: Deluan * feat(watcher): enhance .ndignore handling for folder deletions and file changes Signed-off-by: Deluan * docs(scanner): comments Signed-off-by: Deluan * refactor(scanner): enhance walkDirTree to support target folder scanning Signed-off-by: Deluan * fix(scanner, watcher): handle errors when pushing ignore patterns for folders Signed-off-by: Deluan * Update scanner/phase_1_folders.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor(scanner): replace parseTargets function with direct call to scanner.ParseTargets Signed-off-by: Deluan * test(scanner): add tests for ScanBegin and ScanEnd functionality Signed-off-by: Deluan * fix(library): update PRAGMA optimize to check table sizes without ANALYZE Signed-off-by: Deluan * test(scanner): refactor tests Signed-off-by: Deluan * feat(ui): add selective scan options and update translations Signed-off-by: Deluan * feat(ui): add quick and full scan options for individual libraries Signed-off-by: Deluan * feat(ui): add Scan buttonsto the LibraryList Signed-off-by: Deluan * feat(scan): update scanning parameters from 'path' to 'target' for selective scans. * refactor(scan): move ParseTargets function to model package * test(scan): suppress unused return value from SetUserLibraries in tests * feat(gc): enhance garbage collection to support selective library purging Signed-off-by: Deluan * fix(scanner): prevent race condition when scanning deleted folders When the watcher detects changes in a folder that gets deleted before the scanner runs (due to the 10-second delay), the scanner was prematurely removing these folders from the tracking map, preventing them from being marked as missing. The issue occurred because `newFolderEntry` was calling `popLastUpdate` before verifying the folder actually exists on the filesystem. Changes: - Move fs.Stat check before newFolderEntry creation in loadDir to ensure deleted folders remain in lastUpdates for finalize() to handle - Add early existence check in walkDirTree to skip non-existent target folders with a warning log - Add unit test verifying non-existent folders aren't removed from lastUpdates prematurely - Add integration test for deleted folder scenario with ScanFolders Fixes the issue where deleting entire folders (e.g., /music/AC_DC) wouldn't mark tracks as missing when using selective folder scanning. * refactor(scan): streamline folder entry creation and update handling Signed-off-by: Deluan * feat(scan): add '@Recycle' (QNAP) to ignored directories list Signed-off-by: Deluan * fix(log): improve thread safety in logging level management * test(scan): move unit tests for ParseTargets function Signed-off-by: Deluan * review Signed-off-by: Deluan --------- Signed-off-by: Deluan Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: deluan --- cmd/scan.go | 17 +- cmd/wire_gen.go | 22 +- cmd/wire_injectors.go | 3 +- conf/configuration.go | 2 + core/library.go | 13 +- core/library_test.go | 52 +-- core/maintenance_test.go | 30 +- log/log.go | 17 +- model/datastore.go | 2 +- model/folder.go | 2 +- model/scanner.go | 81 ++++ model/scanner_test.go | 89 ++++ persistence/album_repository.go | 6 +- persistence/folder_repository.go | 52 ++- persistence/folder_repository_test.go | 213 ++++++++++ persistence/library_repository.go | 4 +- persistence/library_repository_test.go | 58 +++ persistence/persistence.go | 12 +- resources/i18n/pt-br.json | 12 +- scanner/controller.go | 42 +- scanner/controller_test.go | 3 +- scanner/external.go | 34 +- scanner/folder_entry.go | 8 +- scanner/folder_entry_test.go | 25 +- scanner/ignore_checker.go | 163 +++++++ scanner/ignore_checker_test.go | 313 ++++++++++++++ scanner/phase_1_folders.go | 78 ++-- scanner/phase_2_missing_tracks.go | 3 - scanner/phase_3_refresh_albums.go | 7 +- scanner/phase_3_refresh_albums_test.go | 4 +- scanner/scanner.go | 121 +++++- scanner/scanner_multilibrary_test.go | 2 +- scanner/scanner_selective_test.go | 293 +++++++++++++ scanner/scanner_test.go | 66 ++- scanner/walk_dir_tree.go | 114 +++-- scanner/walk_dir_tree_test.go | 244 ++++++++--- scanner/watcher.go | 121 +++++- scanner/watcher_test.go | 491 ++++++++++++++++++++++ server/subsonic/api.go | 5 +- server/subsonic/library_scanning.go | 50 ++- server/subsonic/library_scanning_test.go | 396 +++++++++++++++++ tests/mock_data_store.go | 10 +- tests/mock_scanner.go | 120 ++++++ ui/src/i18n/en.json | 12 +- ui/src/layout/ActivityPanel.jsx | 3 + ui/src/library/LibraryList.jsx | 5 +- ui/src/library/LibraryListActions.jsx | 30 ++ ui/src/library/LibraryListBulkActions.jsx | 11 + ui/src/library/LibraryScanButton.jsx | 77 ++++ ui/src/subsonic/index.js | 8 +- utils/slice/slice.go | 11 + utils/slice/slice_test.go | 38 ++ 52 files changed, 3221 insertions(+), 374 deletions(-) create mode 100644 model/scanner.go create mode 100644 model/scanner_test.go create mode 100644 persistence/folder_repository_test.go create mode 100644 scanner/ignore_checker.go create mode 100644 scanner/ignore_checker_test.go create mode 100644 scanner/scanner_selective_test.go create mode 100644 scanner/watcher_test.go create mode 100644 server/subsonic/library_scanning_test.go create mode 100644 tests/mock_scanner.go create mode 100644 ui/src/library/LibraryListActions.jsx create mode 100644 ui/src/library/LibraryListBulkActions.jsx create mode 100644 ui/src/library/LibraryScanButton.jsx diff --git a/cmd/scan.go b/cmd/scan.go index d37ccd69f..41d281070 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -4,10 +4,12 @@ import ( "context" "encoding/gob" "os" + "strings" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/utils/pl" @@ -17,11 +19,13 @@ import ( var ( fullScan bool subprocess bool + targets string ) func init() { scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps") scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)") + scanCmd.Flags().StringVarP(&targets, "targets", "t", "", "comma-separated list of libraryID:folderPath pairs (e.g., \"1:Music/Rock,1:Music/Jazz,2:Classical\")") rootCmd.AddCommand(scanCmd) } @@ -68,7 +72,18 @@ func runScanner(ctx context.Context) { ds := persistence.New(sqlDB) pls := core.NewPlaylists(ds) - progress, err := scanner.CallScan(ctx, ds, pls, fullScan) + // Parse targets if provided + var scanTargets []model.ScanTarget + if targets != "" { + var err error + scanTargets, err = model.ParseTargets(strings.Split(targets, ",")) + if err != nil { + log.Fatal(ctx, "Failed to parse targets", err) + } + log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets)) + } + + progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets) if err != nil { log.Fatal(ctx, "Failed to scan", err) } diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index bf13dc731..d7b6a3ad2 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -69,9 +69,9 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) broker := events.GetBroker() - scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) - watcher := scanner.GetWatcher(dataStore, scannerScanner) - library := core.NewLibrary(dataStore, scannerScanner, watcher, broker) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + watcher := scanner.GetWatcher(dataStore, modelScanner) + library := core.NewLibrary(dataStore, modelScanner, watcher, broker) maintenance := core.NewMaintenance(dataStore) router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance) return router @@ -95,10 +95,10 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) - scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics) return router } @@ -150,7 +150,7 @@ func CreatePrometheus() metrics.Metrics { return metricsMetrics } -func CreateScanner(ctx context.Context) scanner.Scanner { +func CreateScanner(ctx context.Context) model.Scanner { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() @@ -163,8 +163,8 @@ func CreateScanner(ctx context.Context) scanner.Scanner { cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) - scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) - return scannerScanner + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + return modelScanner } func CreateScanWatcher(ctx context.Context) scanner.Watcher { @@ -180,8 +180,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) - scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) - watcher := scanner.GetWatcher(dataStore, scannerScanner) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher } @@ -202,7 +202,7 @@ func getPluginManager() plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index e8759ac53..595d406b9 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -45,7 +45,6 @@ var allProviders = wire.NewSet( wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), - wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), ) @@ -103,7 +102,7 @@ func CreatePrometheus() metrics.Metrics { )) } -func CreateScanner(ctx context.Context) scanner.Scanner { +func CreateScanner(ctx context.Context) model.Scanner { panic(wire.Build( allProviders, )) diff --git a/conf/configuration.go b/conf/configuration.go index 7292c7dfe..a9fee00e4 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -125,6 +125,7 @@ type configOptions struct { DevAlbumInfoTimeToLive time.Duration DevExternalScanner bool DevScannerThreads uint + DevSelectiveWatcher bool DevInsightsInitialDelay time.Duration DevEnablePlayerInsights bool DevEnablePluginsInsights bool @@ -600,6 +601,7 @@ func setViperDefaults() { viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) viper.SetDefault("devexternalscanner", true) viper.SetDefault("devscannerthreads", 5) + viper.SetDefault("devselectivewatcher", true) viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay) viper.SetDefault("devenableplayerinsights", true) viper.SetDefault("devenablepluginsinsights", true) diff --git a/core/library.go b/core/library.go index 7abd35c8f..f4f55ec5a 100644 --- a/core/library.go +++ b/core/library.go @@ -21,11 +21,6 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -// Scanner interface for triggering scans -type Scanner interface { - ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) -} - // Watcher interface for managing file system watchers type Watcher interface { Watch(ctx context.Context, lib *model.Library) error @@ -43,13 +38,13 @@ type Library interface { type libraryService struct { ds model.DataStore - scanner Scanner + scanner model.Scanner watcher Watcher broker events.Broker } // NewLibrary creates a new Library service -func NewLibrary(ds model.DataStore, scanner Scanner, watcher Watcher, broker events.Broker) Library { +func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker) Library { return &libraryService{ ds: ds, scanner: scanner, @@ -155,7 +150,7 @@ type libraryRepositoryWrapper struct { model.LibraryRepository ctx context.Context ds model.DataStore - scanner Scanner + scanner model.Scanner watcher Watcher broker events.Broker } @@ -192,7 +187,7 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) { return strconv.Itoa(lib.ID), nil } -func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error { +func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error { lib := entity.(*model.Library) libID, err := strconv.Atoi(id) if err != nil { diff --git a/core/library_test.go b/core/library_test.go index bfbb4300a..bf73a62b7 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -29,7 +29,7 @@ var _ = Describe("Library Service", func() { var userRepo *tests.MockedUserRepo var ctx context.Context var tempDir string - var scanner *mockScanner + var scanner *tests.MockScanner var watcherManager *mockWatcherManager var broker *mockEventBroker @@ -43,7 +43,7 @@ var _ = Describe("Library Service", func() { ds.MockedUser = userRepo // Create a mock scanner that tracks calls - scanner = &mockScanner{} + scanner = tests.NewMockScanner() // Create a mock watcher manager watcherManager = &mockWatcherManager{ libraryStates: make(map[int]model.Library), @@ -616,11 +616,12 @@ var _ = Describe("Library Service", func() { // Wait briefly for the goroutine to complete Eventually(func() int { - return scanner.len() + return scanner.GetScanAllCallCount() }, "1s", "10ms").Should(Equal(1)) // Verify scan was called with correct parameters - Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan + calls := scanner.GetScanAllCalls() + Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan }) It("triggers scan when updating library path", func() { @@ -641,11 +642,12 @@ var _ = Describe("Library Service", func() { // Wait briefly for the goroutine to complete Eventually(func() int { - return scanner.len() + return scanner.GetScanAllCallCount() }, "1s", "10ms").Should(Equal(1)) // Verify scan was called with correct parameters - Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan + calls := scanner.GetScanAllCalls() + Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan }) It("does not trigger scan when updating library without path change", func() { @@ -661,7 +663,7 @@ var _ = Describe("Library Service", func() { // Wait a bit to ensure no scan was triggered Consistently(func() int { - return scanner.len() + return scanner.GetScanAllCallCount() }, "100ms", "10ms").Should(Equal(0)) }) @@ -674,7 +676,7 @@ var _ = Describe("Library Service", func() { // Ensure no scan was triggered since creation failed Consistently(func() int { - return scanner.len() + return scanner.GetScanAllCallCount() }, "100ms", "10ms").Should(Equal(0)) }) @@ -691,7 +693,7 @@ var _ = Describe("Library Service", func() { // Ensure no scan was triggered since update failed Consistently(func() int { - return scanner.len() + return scanner.GetScanAllCallCount() }, "100ms", "10ms").Should(Equal(0)) }) @@ -707,11 +709,12 @@ var _ = Describe("Library Service", func() { // Wait briefly for the goroutine to complete Eventually(func() int { - return scanner.len() + return scanner.GetScanAllCallCount() }, "1s", "10ms").Should(Equal(1)) // Verify scan was called with correct parameters - Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan + calls := scanner.GetScanAllCalls() + Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan }) It("does not trigger scan when library deletion fails", func() { @@ -721,7 +724,7 @@ var _ = Describe("Library Service", func() { // Ensure no scan was triggered since deletion failed Consistently(func() int { - return scanner.len() + return scanner.GetScanAllCallCount() }, "100ms", "10ms").Should(Equal(0)) }) @@ -868,31 +871,6 @@ var _ = Describe("Library Service", func() { }) }) -// mockScanner provides a simple mock implementation of core.Scanner for testing -type mockScanner struct { - ScanCalls []ScanCall - mu sync.RWMutex -} - -type ScanCall struct { - FullScan bool -} - -func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) { - m.mu.Lock() - defer m.mu.Unlock() - m.ScanCalls = append(m.ScanCalls, ScanCall{ - FullScan: fullScan, - }) - return []string{}, nil -} - -func (m *mockScanner) len() int { - m.mu.RLock() - defer m.mu.RUnlock() - return len(m.ScanCalls) -} - // mockWatcherManager provides a simple mock implementation of core.Watcher for testing type mockWatcherManager struct { StartedWatchers []model.Library diff --git a/core/maintenance_test.go b/core/maintenance_test.go index 8e8796ffa..09b442438 100644 --- a/core/maintenance_test.go +++ b/core/maintenance_test.go @@ -14,7 +14,7 @@ import ( ) var _ = Describe("Maintenance", func() { - var ds *extendedDataStore + var ds *tests.MockDataStore var mfRepo *extendedMediaFileRepo var service Maintenance var ctx context.Context @@ -42,7 +42,7 @@ var _ = Describe("Maintenance", func() { 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") + Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion") }) It("triggers artist stats refresh and album refresh after deletion", func() { @@ -97,7 +97,7 @@ var _ = Describe("Maintenance", func() { }) // Set GC to return error - ds.gcError = errors.New("gc failed") + ds.GCError = errors.New("gc failed") err := service.DeleteMissingFiles(ctx, []string{"mf1"}) @@ -143,7 +143,7 @@ var _ = Describe("Maintenance", func() { err := service.DeleteAllMissingFiles(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion") + Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion") }) It("returns error if deletion fails", func() { @@ -253,11 +253,8 @@ var _ = Describe("Maintenance", func() { }) // Test helper to create a mock DataStore with controllable behavior -func createTestDataStore() *extendedDataStore { - // Create extended datastore with GC tracking - ds := &extendedDataStore{ - MockDataStore: &tests.MockDataStore{}, - } +func createTestDataStore() *tests.MockDataStore { + ds := &tests.MockDataStore{} // Create extended album repo with Put tracking albumRepo := &extendedAlbumRepo{ @@ -365,18 +362,3 @@ func (m *extendedArtistRepo) IsRefreshStatsCalled() bool { 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) -} diff --git a/log/log.go b/log/log.go index ea34e5dcb..801fd7214 100644 --- a/log/log.go +++ b/log/log.go @@ -80,8 +80,8 @@ var ( // SetLevel sets the global log level used by the simple logger. func SetLevel(l Level) { - currentLevel = l loggerMu.Lock() + currentLevel = l defaultLogger.Level = logrus.TraceLevel loggerMu.Unlock() logrus.SetLevel(logrus.Level(l)) @@ -114,6 +114,8 @@ func levelFromString(l string) Level { // SetLogLevels sets the log levels for specific paths in the codebase. func SetLogLevels(levels map[string]string) { + loggerMu.Lock() + defer loggerMu.Unlock() logLevels = nil for k, v := range levels { logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)}) @@ -172,6 +174,8 @@ func SetDefaultLogger(l *logrus.Logger) { } func CurrentLevel() Level { + loggerMu.RLock() + defer loggerMu.RUnlock() return currentLevel } @@ -220,10 +224,15 @@ func Writer() io.Writer { } func shouldLog(requiredLevel Level, skip int) bool { - if currentLevel >= requiredLevel { + loggerMu.RLock() + level := currentLevel + levels := logLevels + loggerMu.RUnlock() + + if level >= requiredLevel { return true } - if len(logLevels) == 0 { + if len(levels) == 0 { return false } @@ -233,7 +242,7 @@ func shouldLog(requiredLevel Level, skip int) bool { } file = strings.TrimPrefix(file, rootPath) - for _, lp := range logLevels { + for _, lp := range levels { if strings.HasPrefix(file, lp.path) { return lp.level >= requiredLevel } diff --git a/model/datastore.go b/model/datastore.go index 4290e2134..536a37274 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -43,5 +43,5 @@ type DataStore interface { WithTx(block func(tx DataStore) error, scope ...string) error WithTxImmediate(block func(tx DataStore) error, scope ...string) error - GC(ctx context.Context) error + GC(ctx context.Context, libraryIDs ...int) error } diff --git a/model/folder.go b/model/folder.go index f715f8c11..7a769735e 100644 --- a/model/folder.go +++ b/model/folder.go @@ -85,7 +85,7 @@ type FolderRepository interface { GetByPath(lib Library, path string) (*Folder, error) GetAll(...QueryOptions) ([]Folder, error) CountAll(...QueryOptions) (int64, error) - GetLastUpdates(lib Library) (map[string]FolderUpdateInfo, error) + GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error) Put(*Folder) error MarkMissing(missing bool, ids ...string) error GetTouchedWithPlaylists() (FolderCursor, error) diff --git a/model/scanner.go b/model/scanner.go new file mode 100644 index 000000000..389c77f87 --- /dev/null +++ b/model/scanner.go @@ -0,0 +1,81 @@ +package model + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" +) + +// ScanTarget represents a specific folder within a library to be scanned. +// NOTE: This struct is used as a map key, so it should only contain comparable types. +type ScanTarget struct { + LibraryID int + FolderPath string // Relative path within the library, or "" for entire library +} + +func (st ScanTarget) String() string { + return fmt.Sprintf("%d:%s", st.LibraryID, st.FolderPath) +} + +// ScannerStatus holds information about the current scan status +type ScannerStatus struct { + Scanning bool + LastScan time.Time + Count uint32 + FolderCount uint32 + LastError string + ScanType string + ElapsedTime time.Duration +} + +type Scanner interface { + // ScanAll starts a scan of all libraries. This is a blocking operation. + ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) + // ScanFolders scans specific library/folder pairs, recursing into subdirectories. + // If targets is nil, it scans all libraries. This is a blocking operation. + ScanFolders(ctx context.Context, fullScan bool, targets []ScanTarget) (warnings []string, err error) + Status(context.Context) (*ScannerStatus, error) +} + +// ParseTargets parses scan targets strings into ScanTarget structs. +// Example: []string{"1:Music/Rock", "2:Classical"} +func ParseTargets(libFolders []string) ([]ScanTarget, error) { + targets := make([]ScanTarget, 0, len(libFolders)) + + for _, part := range libFolders { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + // Split by the first colon + colonIdx := strings.Index(part, ":") + if colonIdx == -1 { + return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part) + } + + libIDStr := part[:colonIdx] + folderPath := part[colonIdx+1:] + + libID, err := strconv.Atoi(libIDStr) + if err != nil { + return nil, fmt.Errorf("invalid library ID %q: %w", libIDStr, err) + } + if libID <= 0 { + return nil, fmt.Errorf("invalid library ID %q", libIDStr) + } + + targets = append(targets, ScanTarget{ + LibraryID: libID, + FolderPath: folderPath, + }) + } + + if len(targets) == 0 { + return nil, fmt.Errorf("no valid targets found") + } + + return targets, nil +} diff --git a/model/scanner_test.go b/model/scanner_test.go new file mode 100644 index 000000000..8ca0c53fa --- /dev/null +++ b/model/scanner_test.go @@ -0,0 +1,89 @@ +package model_test + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseTargets", func() { + It("parses multiple entries in slice", func() { + targets, err := model.ParseTargets([]string{"1:Music/Rock", "1:Music/Jazz", "2:Classical"}) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(HaveLen(3)) + Expect(targets[0].LibraryID).To(Equal(1)) + Expect(targets[0].FolderPath).To(Equal("Music/Rock")) + Expect(targets[1].LibraryID).To(Equal(1)) + Expect(targets[1].FolderPath).To(Equal("Music/Jazz")) + Expect(targets[2].LibraryID).To(Equal(2)) + Expect(targets[2].FolderPath).To(Equal("Classical")) + }) + + It("handles empty folder paths", func() { + targets, err := model.ParseTargets([]string{"1:", "2:"}) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(HaveLen(2)) + Expect(targets[0].FolderPath).To(Equal("")) + Expect(targets[1].FolderPath).To(Equal("")) + }) + + It("trims whitespace from entries", func() { + targets, err := model.ParseTargets([]string{" 1:Music/Rock", " 2:Classical "}) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(HaveLen(2)) + Expect(targets[0].LibraryID).To(Equal(1)) + Expect(targets[0].FolderPath).To(Equal("Music/Rock")) + Expect(targets[1].LibraryID).To(Equal(2)) + Expect(targets[1].FolderPath).To(Equal("Classical")) + }) + + It("skips empty strings", func() { + targets, err := model.ParseTargets([]string{"1:Music/Rock", "", "2:Classical"}) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(HaveLen(2)) + }) + + It("handles paths with colons", func() { + targets, err := model.ParseTargets([]string{"1:C:/Music/Rock", "2:/path:with:colons"}) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(HaveLen(2)) + Expect(targets[0].FolderPath).To(Equal("C:/Music/Rock")) + Expect(targets[1].FolderPath).To(Equal("/path:with:colons")) + }) + + It("returns error for invalid format without colon", func() { + _, err := model.ParseTargets([]string{"1Music/Rock"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid target format")) + }) + + It("returns error for non-numeric library ID", func() { + _, err := model.ParseTargets([]string{"abc:Music/Rock"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid library ID")) + }) + + It("returns error for negative library ID", func() { + _, err := model.ParseTargets([]string{"-1:Music/Rock"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid library ID")) + }) + + It("returns error for zero library ID", func() { + _, err := model.ParseTargets([]string{"0:Music/Rock"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid library ID")) + }) + + It("returns error for empty input", func() { + _, err := model.ParseTargets([]string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no valid targets found")) + }) + + It("returns error for all empty strings", func() { + _, err := model.ParseTargets([]string{"", " ", ""}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no valid targets found")) + }) +}) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6f9bb3b48..b1ce23e2b 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -337,8 +337,12 @@ on conflict (user_id, item_id, item_type) do update return r.executeSQL(query) } -func (r *albumRepository) purgeEmpty() error { +func (r *albumRepository) purgeEmpty(libraryIDs ...int) error { del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)") + // If libraryIDs are specified, only purge albums from those libraries + if len(libraryIDs) > 0 { + del = del.Where(Eq{"library_id": libraryIDs}) + } c, err := r.executeSQL(del) if err != nil { return fmt.Errorf("purging empty albums: %w", err) diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index 96a9bae82..a586746a0 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -4,7 +4,10 @@ import ( "context" "encoding/json" "fmt" + "os" + "path/filepath" "slices" + "strings" "time" . "github.com/Masterminds/squirrel" @@ -91,8 +94,47 @@ func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) { return r.count(query) } -func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]model.FolderUpdateInfo, error) { - sq := r.newSelect().Columns("id", "updated_at", "hash").Where(Eq{"library_id": lib.ID, "missing": false}) +func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) { + where := And{ + Eq{"library_id": lib.ID}, + Eq{"missing": false}, + } + + // If specific paths are requested, include those folders and all their descendants + if len(targetPaths) > 0 { + // Collect folder IDs for exact target folders and path conditions for descendants + folderIDs := make([]string, 0, len(targetPaths)) + pathConditions := make(Or, 0, len(targetPaths)*2) + + for _, targetPath := range targetPaths { + if targetPath == "" || targetPath == "." { + // Root path - include everything in this library + pathConditions = Or{} + folderIDs = nil + break + } + // Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes. + cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator)) + cleanPath = filepath.Clean(cleanPath) + + // Include the target folder itself by ID + folderIDs = append(folderIDs, model.FolderID(lib, cleanPath)) + + // Include all descendants: folders whose path field equals or starts with the target path + // Note: Folder.Path is the directory path, so children have path = targetPath + pathConditions = append(pathConditions, Eq{"path": cleanPath}) + pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"}) + } + + // Combine conditions: exact folder IDs OR descendant path patterns + if len(folderIDs) > 0 { + where = append(where, Or{Eq{"id": folderIDs}, pathConditions}) + } else if len(pathConditions) > 0 { + where = append(where, pathConditions) + } + } + + sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where) var res []struct { ID string UpdatedAt time.Time @@ -149,7 +191,7 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) }, nil } -func (r folderRepository) purgeEmpty() error { +func (r folderRepository) purgeEmpty(libraryIDs ...int) error { sq := Delete(r.tableName).Where(And{ Eq{"num_audio_files": 0}, Eq{"num_playlists": 0}, @@ -157,6 +199,10 @@ func (r folderRepository) purgeEmpty() error { ConcatExpr("id not in (select parent_id from folder)"), ConcatExpr("id not in (select folder_id from media_file)"), }) + // If libraryIDs are specified, only purge folders from those libraries + if len(libraryIDs) > 0 { + sq = sq.Where(Eq{"library_id": libraryIDs}) + } c, err := r.executeSQL(sq) if err != nil { return fmt.Errorf("purging empty folders: %w", err) diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go new file mode 100644 index 000000000..6c24741c9 --- /dev/null +++ b/persistence/folder_repository_test.go @@ -0,0 +1,213 @@ +package persistence + +import ( + "context" + "fmt" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("FolderRepository", func() { + var repo model.FolderRepository + var ctx context.Context + var conn *dbx.DB + var testLib, otherLib model.Library + + BeforeEach(func() { + ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) + conn = GetDBXBuilder() + repo = newFolderRepository(ctx, conn) + + // Use existing library ID 1 from test fixtures + libRepo := NewLibraryRepository(ctx, conn) + lib, err := libRepo.Get(1) + Expect(err).ToNot(HaveOccurred()) + testLib = *lib + + // Create a second library with its own folder to verify isolation + otherLib = model.Library{Name: "Other Library", Path: "/other/path"} + Expect(libRepo.Put(&otherLib)).To(Succeed()) + }) + + AfterEach(func() { + // Clean up only test folders created by our tests (paths starting with "Test") + // This prevents interference with fixture data needed by other tests + _, _ = conn.NewQuery("DELETE FROM folder WHERE library_id = 1 AND path LIKE 'Test%'").Execute() + _, _ = conn.NewQuery(fmt.Sprintf("DELETE FROM library WHERE id = %d", otherLib.ID)).Execute() + }) + + Describe("GetFolderUpdateInfo", func() { + Context("with no target paths", func() { + It("returns all folders in the library", func() { + // Create test folders with unique names to avoid conflicts + folder1 := model.NewFolder(testLib, "TestGetLastUpdates/Folder1") + folder2 := model.NewFolder(testLib, "TestGetLastUpdates/Folder2") + + err := repo.Put(folder1) + Expect(err).ToNot(HaveOccurred()) + err = repo.Put(folder2) + Expect(err).ToNot(HaveOccurred()) + + otherFolder := model.NewFolder(otherLib, "TestOtherLib/Folder") + err = repo.Put(otherFolder) + Expect(err).ToNot(HaveOccurred()) + + // Query all folders (no target paths) - should only return folders from testLib + results, err := repo.GetFolderUpdateInfo(testLib) + Expect(err).ToNot(HaveOccurred()) + // Should include folders from testLib + Expect(results).To(HaveKey(folder1.ID)) + Expect(results).To(HaveKey(folder2.ID)) + // Should NOT include folders from other library + Expect(results).ToNot(HaveKey(otherFolder.ID)) + }) + }) + + Context("with specific target paths", func() { + It("returns folder info for existing folders", func() { + // Create test folders with unique names + folder1 := model.NewFolder(testLib, "TestSpecific/Rock") + folder2 := model.NewFolder(testLib, "TestSpecific/Jazz") + folder3 := model.NewFolder(testLib, "TestSpecific/Classical") + + err := repo.Put(folder1) + Expect(err).ToNot(HaveOccurred()) + err = repo.Put(folder2) + Expect(err).ToNot(HaveOccurred()) + err = repo.Put(folder3) + Expect(err).ToNot(HaveOccurred()) + + // Query specific paths + results, err := repo.GetFolderUpdateInfo(testLib, "TestSpecific/Rock", "TestSpecific/Classical") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + + // Verify folder IDs are in results + Expect(results).To(HaveKey(folder1.ID)) + Expect(results).To(HaveKey(folder3.ID)) + Expect(results).ToNot(HaveKey(folder2.ID)) + + // Verify update info is populated + Expect(results[folder1.ID].UpdatedAt).ToNot(BeZero()) + Expect(results[folder1.ID].Hash).To(Equal(folder1.Hash)) + }) + + It("includes all child folders when querying parent", func() { + // Create a parent folder with multiple children + parent := model.NewFolder(testLib, "TestParent/Music") + child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen") + child2 := model.NewFolder(testLib, "TestParent/Music/Jazz") + otherParent := model.NewFolder(testLib, "TestParent2/Music/Jazz") + + Expect(repo.Put(parent)).To(Succeed()) + Expect(repo.Put(child1)).To(Succeed()) + Expect(repo.Put(child2)).To(Succeed()) + + // Query the parent folder - should return parent and all children + results, err := repo.GetFolderUpdateInfo(testLib, "TestParent/Music") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results).To(HaveKey(parent.ID)) + Expect(results).To(HaveKey(child1.ID)) + Expect(results).To(HaveKey(child2.ID)) + Expect(results).ToNot(HaveKey(otherParent.ID)) + }) + + It("excludes children from other libraries", func() { + // Create parent in testLib + parent := model.NewFolder(testLib, "TestIsolation/Parent") + child := model.NewFolder(testLib, "TestIsolation/Parent/Child") + + Expect(repo.Put(parent)).To(Succeed()) + Expect(repo.Put(child)).To(Succeed()) + + // Create similar path in other library + otherParent := model.NewFolder(otherLib, "TestIsolation/Parent") + otherChild := model.NewFolder(otherLib, "TestIsolation/Parent/Child") + + Expect(repo.Put(otherParent)).To(Succeed()) + Expect(repo.Put(otherChild)).To(Succeed()) + + // Query should only return folders from testLib + results, err := repo.GetFolderUpdateInfo(testLib, "TestIsolation/Parent") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect(results).To(HaveKey(parent.ID)) + Expect(results).To(HaveKey(child.ID)) + Expect(results).ToNot(HaveKey(otherParent.ID)) + Expect(results).ToNot(HaveKey(otherChild.ID)) + }) + + It("excludes missing children when querying parent", func() { + // Create parent and children, mark one as missing + parent := model.NewFolder(testLib, "TestMissingChild/Parent") + child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1") + child2 := model.NewFolder(testLib, "TestMissingChild/Parent/Child2") + child2.Missing = true + + Expect(repo.Put(parent)).To(Succeed()) + Expect(repo.Put(child1)).To(Succeed()) + Expect(repo.Put(child2)).To(Succeed()) + + // Query parent - should only return parent and non-missing child + results, err := repo.GetFolderUpdateInfo(testLib, "TestMissingChild/Parent") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect(results).To(HaveKey(parent.ID)) + Expect(results).To(HaveKey(child1.ID)) + Expect(results).ToNot(HaveKey(child2.ID)) + }) + + It("handles mix of existing and non-existing target paths", func() { + // Create folders for one path but not the other + existingParent := model.NewFolder(testLib, "TestMixed/Exists") + existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child") + + Expect(repo.Put(existingParent)).To(Succeed()) + Expect(repo.Put(existingChild)).To(Succeed()) + + // Query both existing and non-existing paths + results, err := repo.GetFolderUpdateInfo(testLib, "TestMixed/Exists", "TestMixed/DoesNotExist") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect(results).To(HaveKey(existingParent.ID)) + Expect(results).To(HaveKey(existingChild.ID)) + }) + + It("handles empty folder path as root", func() { + // Test querying for root folder without creating it (fixtures should have one) + rootFolderID := model.FolderID(testLib, ".") + + results, err := repo.GetFolderUpdateInfo(testLib, "") + Expect(err).ToNot(HaveOccurred()) + // Should return the root folder if it exists + if len(results) > 0 { + Expect(results).To(HaveKey(rootFolderID)) + } + }) + + It("returns empty map for non-existent folders", func() { + results, err := repo.GetFolderUpdateInfo(testLib, "NonExistent/Path") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("skips missing folders", func() { + // Create a folder and mark it as missing + folder := model.NewFolder(testLib, "TestMissing/Folder") + folder.Missing = true + err := repo.Put(folder) + Expect(err).ToNot(HaveOccurred()) + + results, err := repo.GetFolderUpdateInfo(testLib, "TestMissing/Folder") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + }) + }) +}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 314b682bb..5621e1719 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -177,7 +177,9 @@ func (r *libraryRepository) ScanEnd(id int) error { return err } // https://www.sqlite.org/pragma.html#pragma_optimize - _, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;")) + // Use mask 0x10000 to check table sizes without running ANALYZE + // Running ANALYZE can cause query planner issues with expression-based collation indexes + _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;")) return err } diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 6f4df1beb..3e3972bdb 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -142,4 +142,62 @@ var _ = Describe("LibraryRepository", func() { Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum)) Expect(libAfter.TotalDuration).To(Equal(durationRes.Sum)) }) + + Describe("ScanBegin and ScanEnd", func() { + var lib *model.Library + + BeforeEach(func() { + lib = &model.Library{ + ID: 0, + Name: "Test Scan Library", + Path: "/music/test-scan", + } + err := repo.Put(lib) + Expect(err).ToNot(HaveOccurred()) + }) + + DescribeTable("ScanBegin", + func(fullScan bool, expectedFullScanInProgress bool) { + err := repo.ScanBegin(lib.ID, fullScan) + Expect(err).ToNot(HaveOccurred()) + + updatedLib, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updatedLib.LastScanStartedAt).ToNot(BeZero()) + Expect(updatedLib.FullScanInProgress).To(Equal(expectedFullScanInProgress)) + }, + Entry("sets FullScanInProgress to true for full scan", true, true), + Entry("sets FullScanInProgress to false for quick scan", false, false), + ) + + Context("ScanEnd", func() { + BeforeEach(func() { + err := repo.ScanBegin(lib.ID, true) + Expect(err).ToNot(HaveOccurred()) + }) + + It("sets LastScanAt and clears FullScanInProgress and LastScanStartedAt", func() { + err := repo.ScanEnd(lib.ID) + Expect(err).ToNot(HaveOccurred()) + + updatedLib, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updatedLib.LastScanAt).ToNot(BeZero()) + Expect(updatedLib.FullScanInProgress).To(BeFalse()) + Expect(updatedLib.LastScanStartedAt).To(BeZero()) + }) + + It("sets LastScanAt to be after LastScanStartedAt", func() { + libBefore, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + + err = repo.ScanEnd(lib.ID) + Expect(err).ToNot(HaveOccurred()) + + libAfter, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(libAfter.LastScanAt).To(BeTemporally(">=", libBefore.LastScanStartedAt)) + }) + }) + }) }) diff --git a/persistence/persistence.go b/persistence/persistence.go index ac607f85f..1de0bae61 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -157,7 +157,7 @@ func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope . }, scope...) } -func (s *SQLStore) GC(ctx context.Context) error { +func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace := func(ctx context.Context, msg string, f func() error) func() error { return func() error { start := time.Now() @@ -167,11 +167,17 @@ func (s *SQLStore) GC(ctx context.Context) error { } } + // If libraryIDs are provided, scope operations to those libraries where possible + scoped := len(libraryIDs) > 0 + if scoped { + log.Debug(ctx, "GC: Running selective garbage collection", "libraryIDs", libraryIDs) + } + err := run.Sequentially( - trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }), + trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty(libraryIDs...) }), trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }), trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }), - trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }), + trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty(libraryIDs...) }), trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }), trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }), trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }), diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 9c22d509f..3f095b025 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -300,6 +300,8 @@ }, "actions": { "scan": "Scanear Biblioteca", + "quickScan": "Scan Rápido", + "fullScan": "Scan Completo", "manageUsers": "Gerenciar Acesso do Usuário", "viewDetails": "Ver Detalhes" }, @@ -308,6 +310,9 @@ "updated": "Biblioteca atualizada com sucesso", "deleted": "Biblioteca excluída com sucesso", "scanStarted": "Scan da biblioteca iniciada", + "quickScanStarted": "Scan rápido iniciado", + "fullScanStarted": "Scan completo iniciado", + "scanError": "Erro ao iniciar o scan. Verifique os logs", "scanCompleted": "Scan da biblioteca concluída" }, "validation": { @@ -598,11 +603,12 @@ "activity": { "title": "Atividade", "totalScanned": "Total de pastas scaneadas", - "quickScan": "Scan rápido", - "fullScan": "Scan completo", + "quickScan": "Rápido", + "fullScan": "Completo", + "selectiveScan": "Seletivo", "serverUptime": "Uptime do servidor", "serverDown": "DESCONECTADO", - "scanType": "Tipo", + "scanType": "Último Scan", "status": "Erro", "elapsedTime": "Duração" }, diff --git a/scanner/controller.go b/scanner/controller.go index c1347077a..b42246a50 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -26,24 +26,8 @@ var ( ErrAlreadyScanning = errors.New("already scanning") ) -type Scanner interface { - // ScanAll starts a full scan of the music library. This is a blocking operation. - ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) - Status(context.Context) (*StatusInfo, error) -} - -type StatusInfo struct { - Scanning bool - LastScan time.Time - Count uint32 - FolderCount uint32 - LastError string - ScanType string - ElapsedTime time.Duration -} - func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker, - pls core.Playlists, m metrics.Metrics) Scanner { + pls core.Playlists, m metrics.Metrics) model.Scanner { c := &controller{ rootCtx: rootCtx, ds: ds, @@ -65,9 +49,10 @@ func (s *controller) getScanner() scanner { return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls} } -// CallScan starts an in-process scan of the music library. +// CallScan starts an in-process scan of specific library/folder pairs. +// If targets is empty, it scans all libraries. // This is meant to be called from the command line (see cmd/scan.go). -func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool) (<-chan *ProgressInfo, error) { +func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) { release, err := lockScan(ctx) if err != nil { return nil, err @@ -79,7 +64,7 @@ func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullS go func() { defer close(progress) scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls} - scanner.scanAll(ctx, fullScan, progress) + scanner.scanFolders(ctx, fullScan, targets, progress) }() return progress, nil } @@ -99,8 +84,11 @@ type ProgressInfo struct { ForceUpdate bool } +// scanner defines the interface for different scanner implementations. +// This allows for swapping between in-process and external scanners. type scanner interface { - scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo) + // scanFolders performs the actual scanning of folders. If targets is nil, it scans all libraries. + scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) } type controller struct { @@ -158,7 +146,7 @@ func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed return scanType, elapsed, lastErr } -func (s *controller) Status(ctx context.Context) (*StatusInfo, error) { +func (s *controller) Status(ctx context.Context) (*model.ScannerStatus, error) { lastScanTime, err := s.getLastScanTime(ctx) if err != nil { return nil, fmt.Errorf("getting last scan time: %w", err) @@ -167,7 +155,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) { scanType, elapsed, lastErr := s.getScanInfo(ctx) if running.Load() { - status := &StatusInfo{ + status := &model.ScannerStatus{ Scanning: true, LastScan: lastScanTime, Count: s.count.Load(), @@ -183,7 +171,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) { if err != nil { return nil, fmt.Errorf("getting library stats: %w", err) } - return &StatusInfo{ + return &model.ScannerStatus{ Scanning: false, LastScan: lastScanTime, Count: uint32(count), @@ -208,6 +196,10 @@ func (s *controller) getCounters(ctx context.Context) (int64, int64, error) { } func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]string, error) { + return s.ScanFolders(requestCtx, fullScan, nil) +} + +func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) { release, err := lockScan(requestCtx) if err != nil { return nil, err @@ -224,7 +216,7 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin go func() { defer close(progress) scanner := s.getScanner() - scanner.scanAll(ctx, fullScan, progress) + scanner.scanFolders(ctx, fullScan, targets, progress) }() // Wait for the scan to finish, sending progress events to all connected clients diff --git a/scanner/controller_test.go b/scanner/controller_test.go index e551e15b1..f5ccabc86 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/events" @@ -20,7 +21,7 @@ import ( var _ = Describe("Controller", func() { var ctx context.Context var ds *tests.MockDataStore - var ctrl scanner.Scanner + var ctrl model.Scanner Describe("Status", func() { BeforeEach(func() { diff --git a/scanner/external.go b/scanner/external.go index c4a29efa3..b6d7639be 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -8,10 +8,12 @@ import ( "io" "os" "os/exec" + "strings" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" - . "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" ) // scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid @@ -23,19 +25,41 @@ import ( // process will forward them to the caller. type scannerExternal struct{} -func (s *scannerExternal) scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo) { +func (s *scannerExternal) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { + s.scan(ctx, fullScan, targets, progress) +} + +func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { exe, err := os.Executable() if err != nil { progress <- &ProgressInfo{Error: fmt.Sprintf("failed to get executable path: %s", err)} return } - log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe) - cmd := exec.CommandContext(ctx, exe, "scan", + + // Build command arguments + args := []string{ + "scan", "--nobanner", "--subprocess", "--configfile", conf.Server.ConfigFile, "--datafolder", conf.Server.DataFolder, "--cachefolder", conf.Server.CacheFolder, - If(fullScan, "--full", "")) + } + + // Add targets if provided + if len(targets) > 0 { + targetsStr := strings.Join(slice.Map(targets, func(t model.ScanTarget) string { return t.String() }), ",") + args = append(args, "--targets", targetsStr) + log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targetsStr) + } else { + log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe) + } + + // Add full scan flag if needed + if fullScan { + args = append(args, "--full") + } + + cmd := exec.CommandContext(ctx, exe, args...) in, out := io.Pipe() defer in.Close() diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go index fc68cb561..9d8d0c571 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -15,9 +15,7 @@ import ( "github.com/navidrome/navidrome/utils/chrono" ) -func newFolderEntry(job *scanJob, path string) *folderEntry { - id := model.FolderID(job.lib, path) - info := job.popLastUpdate(id) +func newFolderEntry(job *scanJob, id, path string, updTime time.Time, hash string) *folderEntry { f := &folderEntry{ id: id, job: job, @@ -25,8 +23,8 @@ func newFolderEntry(job *scanJob, path string) *folderEntry { audioFiles: make(map[string]fs.DirEntry), imageFiles: make(map[string]fs.DirEntry), albumIDMap: make(map[string]string), - updTime: info.UpdatedAt, - prevHash: info.Hash, + updTime: updTime, + prevHash: hash, } return f } diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go index c6d1b2ce4..0328c6653 100644 --- a/scanner/folder_entry_test.go +++ b/scanner/folder_entry_test.go @@ -40,9 +40,8 @@ var _ = Describe("folder_entry", func() { UpdatedAt: time.Now().Add(-30 * time.Minute), Hash: "previous-hash", } - job.lastUpdates[folderID] = updateInfo - entry := newFolderEntry(job, path) + entry := newFolderEntry(job, folderID, path, updateInfo.UpdatedAt, updateInfo.Hash) Expect(entry.id).To(Equal(folderID)) Expect(entry.job).To(Equal(job)) @@ -53,15 +52,10 @@ var _ = Describe("folder_entry", func() { Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt)) Expect(entry.prevHash).To(Equal(updateInfo.Hash)) }) + }) - It("creates a new folder entry with zero time when no previous update exists", func() { - entry := newFolderEntry(job, path) - - Expect(entry.updTime).To(BeZero()) - Expect(entry.prevHash).To(BeEmpty()) - }) - - It("removes the lastUpdate from the job after popping", func() { + Describe("createFolderEntry", func() { + It("removes the lastUpdate from the job after creation", func() { folderID := model.FolderID(lib, path) updateInfo := model.FolderUpdateInfo{ UpdatedAt: time.Now().Add(-30 * time.Minute), @@ -69,8 +63,10 @@ var _ = Describe("folder_entry", func() { } job.lastUpdates[folderID] = updateInfo - newFolderEntry(job, path) + entry := job.createFolderEntry(path) + Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt)) + Expect(entry.prevHash).To(Equal(updateInfo.Hash)) Expect(job.lastUpdates).ToNot(HaveKey(folderID)) }) }) @@ -79,7 +75,8 @@ var _ = Describe("folder_entry", func() { var entry *folderEntry BeforeEach(func() { - entry = newFolderEntry(job, path) + folderID := model.FolderID(lib, path) + entry = newFolderEntry(job, folderID, path, time.Time{}, "") }) Describe("hasNoFiles", func() { @@ -458,7 +455,9 @@ var _ = Describe("folder_entry", func() { Describe("integration scenarios", func() { It("handles complete folder lifecycle", func() { // Create new folder entry - entry := newFolderEntry(job, "music/rock/album") + folderPath := "music/rock/album" + folderID := model.FolderID(lib, folderPath) + entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "") // Initially new and has no files Expect(entry.isNew()).To(BeTrue()) diff --git a/scanner/ignore_checker.go b/scanner/ignore_checker.go new file mode 100644 index 000000000..da74293fa --- /dev/null +++ b/scanner/ignore_checker.go @@ -0,0 +1,163 @@ +package scanner + +import ( + "bufio" + "context" + "io/fs" + "path" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + ignore "github.com/sabhiram/go-gitignore" +) + +// IgnoreChecker manages .ndignore patterns using a stack-based approach. +// Use Push() to add patterns when entering a folder, Pop() when leaving, +// and ShouldIgnore() to check if a path should be ignored. +type IgnoreChecker struct { + fsys fs.FS + patternStack [][]string // Stack of patterns for each folder level + currentPatterns []string // Flattened current patterns + matcher *ignore.GitIgnore // Compiled matcher for current patterns +} + +// newIgnoreChecker creates a new IgnoreChecker for the given filesystem. +func newIgnoreChecker(fsys fs.FS) *IgnoreChecker { + return &IgnoreChecker{ + fsys: fsys, + patternStack: make([][]string, 0), + } +} + +// Push loads .ndignore patterns from the specified folder and adds them to the pattern stack. +// Use this when entering a folder during directory tree traversal. +func (ic *IgnoreChecker) Push(ctx context.Context, folder string) error { + patterns := ic.loadPatternsFromFolder(ctx, folder) + ic.patternStack = append(ic.patternStack, patterns) + ic.rebuildCurrentPatterns() + return nil +} + +// Pop removes the most recent patterns from the stack. +// Use this when leaving a folder during directory tree traversal. +func (ic *IgnoreChecker) Pop() { + if len(ic.patternStack) > 0 { + ic.patternStack = ic.patternStack[:len(ic.patternStack)-1] + ic.rebuildCurrentPatterns() + } +} + +// PushAllParents pushes patterns from root down to the target path. +// This is a convenience method for when you need to check a specific path +// without recursively walking the tree. It handles the common pattern of +// pushing all parent directories from root to the target. +// This method is optimized to compile patterns only once at the end. +func (ic *IgnoreChecker) PushAllParents(ctx context.Context, targetPath string) error { + if targetPath == "." || targetPath == "" { + // Simple case: just push root + return ic.Push(ctx, ".") + } + + // Load patterns for root + patterns := ic.loadPatternsFromFolder(ctx, ".") + ic.patternStack = append(ic.patternStack, patterns) + + // Load patterns for each parent directory + currentPath := "." + parts := strings.Split(path.Clean(targetPath), "/") + for _, part := range parts { + if part == "." || part == "" { + continue + } + currentPath = path.Join(currentPath, part) + patterns = ic.loadPatternsFromFolder(ctx, currentPath) + ic.patternStack = append(ic.patternStack, patterns) + } + + // Rebuild and compile patterns only once at the end + ic.rebuildCurrentPatterns() + return nil +} + +// 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. +func (ic *IgnoreChecker) ShouldIgnore(ctx context.Context, relPath string) bool { + // Handle root/empty path - never ignore + if relPath == "" || relPath == "." { + return false + } + + // If no patterns loaded, nothing to ignore + if ic.matcher == nil { + return false + } + + matches := ic.matcher.MatchesPath(relPath) + if matches { + log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore", "path", relPath) + } + return matches +} + +// 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 exists but is empty, returns a pattern to ignore everything ("**/*"). +func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder string) []string { + ignoreFilePath := path.Join(folder, consts.ScanIgnoreFile) + var patterns []string + + // Check if .ndignore file exists + if _, err := fs.Stat(ic.fsys, ignoreFilePath); err != nil { + // No .ndignore file in this folder + return patterns + } + + // Read and parse the .ndignore file + ignoreFile, err := ic.fsys.Open(ignoreFilePath) + if err != nil { + log.Warn(ctx, "Scanner: Error opening .ndignore file", "path", ignoreFilePath, err) + return patterns + } + defer ignoreFile.Close() + + lineScanner := bufio.NewScanner(ignoreFile) + for lineScanner.Scan() { + line := strings.TrimSpace(lineScanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue // Skip empty lines, whitespace-only lines, and comments + } + patterns = append(patterns, line) + } + + if err := lineScanner.Err(); err != nil { + log.Warn(ctx, "Scanner: Error reading .ndignore file", "path", ignoreFilePath, err) + return patterns + } + + // If the .ndignore file is empty, ignore everything + if len(patterns) == 0 { + log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", folder) + patterns = []string{"**/*"} + } + + return patterns +} + +// rebuildCurrentPatterns flattens the pattern stack into currentPatterns and recompiles the matcher. +func (ic *IgnoreChecker) rebuildCurrentPatterns() { + ic.currentPatterns = make([]string, 0) + for _, patterns := range ic.patternStack { + ic.currentPatterns = append(ic.currentPatterns, patterns...) + } + ic.compilePatterns() +} + +// compilePatterns compiles the current patterns into a GitIgnore matcher. +func (ic *IgnoreChecker) compilePatterns() { + if len(ic.currentPatterns) == 0 { + ic.matcher = nil + return + } + ic.matcher = ignore.CompileIgnoreLines(ic.currentPatterns...) +} diff --git a/scanner/ignore_checker_test.go b/scanner/ignore_checker_test.go new file mode 100644 index 000000000..5378ed4fa --- /dev/null +++ b/scanner/ignore_checker_test.go @@ -0,0 +1,313 @@ +package scanner + +import ( + "context" + "testing/fstest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("IgnoreChecker", func() { + Describe("loadPatternsFromFolder", func() { + var ic *IgnoreChecker + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + Context("when .ndignore file does not exist", func() { + It("should return empty patterns", func() { + fsys := fstest.MapFS{} + ic = newIgnoreChecker(fsys) + patterns := ic.loadPatternsFromFolder(ctx, ".") + Expect(patterns).To(BeEmpty()) + }) + }) + + Context("when .ndignore file is empty", func() { + It("should return wildcard to ignore everything", func() { + fsys := fstest.MapFS{ + ".ndignore": &fstest.MapFile{Data: []byte("")}, + } + ic = newIgnoreChecker(fsys) + patterns := ic.loadPatternsFromFolder(ctx, ".") + Expect(patterns).To(Equal([]string{"**/*"})) + }) + }) + + DescribeTable("parsing .ndignore content", + func(content string, expectedPatterns []string) { + fsys := fstest.MapFS{ + ".ndignore": &fstest.MapFile{Data: []byte(content)}, + } + ic = newIgnoreChecker(fsys) + patterns := ic.loadPatternsFromFolder(ctx, ".") + Expect(patterns).To(Equal(expectedPatterns)) + }, + Entry("single pattern", "*.txt", []string{"*.txt"}), + Entry("multiple patterns", "*.txt\n*.log", []string{"*.txt", "*.log"}), + Entry("with comments", "# comment\n*.txt\n# another\n*.log", []string{"*.txt", "*.log"}), + Entry("with empty lines", "*.txt\n\n*.log\n\n", []string{"*.txt", "*.log"}), + Entry("mixed content", "# header\n\n*.txt\n# middle\n*.log\n\n", []string{"*.txt", "*.log"}), + Entry("only comments and empty lines", "# comment\n\n# another\n", []string{"**/*"}), + Entry("trailing newline", "*.txt\n*.log\n", []string{"*.txt", "*.log"}), + Entry("directory pattern", "temp/", []string{"temp/"}), + Entry("wildcard pattern", "**/*.mp3", []string{"**/*.mp3"}), + Entry("multiple wildcards", "**/*.mp3\n**/*.flac\n*.log", []string{"**/*.mp3", "**/*.flac", "*.log"}), + Entry("negation pattern", "!important.txt", []string{"!important.txt"}), + Entry("comment with hash not at start is pattern", "not#comment", []string{"not#comment"}), + Entry("whitespace-only lines skipped", "*.txt\n \n*.log\n\t\n", []string{"*.txt", "*.log"}), + Entry("patterns with whitespace trimmed", " *.txt \n\t*.log\t", []string{"*.txt", "*.log"}), + ) + }) + + Describe("Push and Pop", func() { + var ic *IgnoreChecker + var fsys fstest.MapFS + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + fsys = fstest.MapFS{ + ".ndignore": &fstest.MapFile{Data: []byte("*.txt")}, + "folder1/.ndignore": &fstest.MapFile{Data: []byte("*.mp3")}, + "folder2/.ndignore": &fstest.MapFile{Data: []byte("*.flac")}, + } + ic = newIgnoreChecker(fsys) + }) + + Context("Push", func() { + It("should add patterns to stack", func() { + err := ic.Push(ctx, ".") + Expect(err).ToNot(HaveOccurred()) + Expect(len(ic.patternStack)).To(Equal(1)) + Expect(ic.currentPatterns).To(ContainElement("*.txt")) + }) + + It("should compile matcher after push", func() { + err := ic.Push(ctx, ".") + Expect(err).ToNot(HaveOccurred()) + Expect(ic.matcher).ToNot(BeNil()) + }) + + It("should accumulate patterns from multiple levels", func() { + err := ic.Push(ctx, ".") + Expect(err).ToNot(HaveOccurred()) + err = ic.Push(ctx, "folder1") + Expect(err).ToNot(HaveOccurred()) + Expect(len(ic.patternStack)).To(Equal(2)) + Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.mp3")) + }) + + It("should handle push when no .ndignore exists", func() { + err := ic.Push(ctx, "nonexistent") + Expect(err).ToNot(HaveOccurred()) + Expect(len(ic.patternStack)).To(Equal(1)) + Expect(ic.currentPatterns).To(BeEmpty()) + }) + }) + + Context("Pop", func() { + It("should remove most recent patterns", func() { + err := ic.Push(ctx, ".") + Expect(err).ToNot(HaveOccurred()) + err = ic.Push(ctx, "folder1") + Expect(err).ToNot(HaveOccurred()) + ic.Pop() + Expect(len(ic.patternStack)).To(Equal(1)) + Expect(ic.currentPatterns).To(Equal([]string{"*.txt"})) + }) + + It("should handle Pop on empty stack gracefully", func() { + Expect(func() { ic.Pop() }).ToNot(Panic()) + Expect(ic.patternStack).To(BeEmpty()) + }) + + It("should set matcher to nil when all patterns popped", func() { + err := ic.Push(ctx, ".") + Expect(err).ToNot(HaveOccurred()) + Expect(ic.matcher).ToNot(BeNil()) + ic.Pop() + Expect(ic.matcher).To(BeNil()) + }) + + It("should update matcher after pop", func() { + err := ic.Push(ctx, ".") + Expect(err).ToNot(HaveOccurred()) + err = ic.Push(ctx, "folder1") + Expect(err).ToNot(HaveOccurred()) + matcher1 := ic.matcher + ic.Pop() + matcher2 := ic.matcher + Expect(matcher1).ToNot(Equal(matcher2)) + }) + }) + + Context("multiple Push/Pop cycles", func() { + It("should maintain correct state through cycles", func() { + err := ic.Push(ctx, ".") + Expect(err).ToNot(HaveOccurred()) + Expect(ic.currentPatterns).To(Equal([]string{"*.txt"})) + + err = ic.Push(ctx, "folder1") + Expect(err).ToNot(HaveOccurred()) + Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.mp3")) + + ic.Pop() + Expect(ic.currentPatterns).To(Equal([]string{"*.txt"})) + + err = ic.Push(ctx, "folder2") + Expect(err).ToNot(HaveOccurred()) + Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.flac")) + + ic.Pop() + Expect(ic.currentPatterns).To(Equal([]string{"*.txt"})) + + ic.Pop() + Expect(ic.currentPatterns).To(BeEmpty()) + }) + }) + }) + + Describe("PushAllParents", func() { + var ic *IgnoreChecker + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + fsys := fstest.MapFS{ + ".ndignore": &fstest.MapFile{Data: []byte("root.txt")}, + "folder1/.ndignore": &fstest.MapFile{Data: []byte("level1.txt")}, + "folder1/folder2/.ndignore": &fstest.MapFile{Data: []byte("level2.txt")}, + "folder1/folder2/folder3/.ndignore": &fstest.MapFile{Data: []byte("level3.txt")}, + } + ic = newIgnoreChecker(fsys) + }) + + DescribeTable("loading parent patterns", + func(targetPath string, expectedStackDepth int, expectedPatterns []string) { + err := ic.PushAllParents(ctx, targetPath) + Expect(err).ToNot(HaveOccurred()) + Expect(len(ic.patternStack)).To(Equal(expectedStackDepth)) + Expect(ic.currentPatterns).To(ConsistOf(expectedPatterns)) + }, + Entry("root path", ".", 1, []string{"root.txt"}), + Entry("empty path", "", 1, []string{"root.txt"}), + Entry("single level", "folder1", 2, []string{"root.txt", "level1.txt"}), + Entry("two levels", "folder1/folder2", 3, []string{"root.txt", "level1.txt", "level2.txt"}), + Entry("three levels", "folder1/folder2/folder3", 4, []string{"root.txt", "level1.txt", "level2.txt", "level3.txt"}), + ) + + It("should only compile patterns once at the end", func() { + // This is more of a behavioral test - we verify the matcher is not nil after PushAllParents + err := ic.PushAllParents(ctx, "folder1/folder2") + Expect(err).ToNot(HaveOccurred()) + Expect(ic.matcher).ToNot(BeNil()) + }) + + It("should handle paths with dot", func() { + err := ic.PushAllParents(ctx, "./folder1") + Expect(err).ToNot(HaveOccurred()) + Expect(len(ic.patternStack)).To(Equal(2)) + }) + + Context("when some parent folders have no .ndignore", func() { + BeforeEach(func() { + fsys := fstest.MapFS{ + ".ndignore": &fstest.MapFile{Data: []byte("root.txt")}, + "folder1/folder2/.ndignore": &fstest.MapFile{Data: []byte("level2.txt")}, + } + ic = newIgnoreChecker(fsys) + }) + + It("should still push all parent levels", func() { + err := ic.PushAllParents(ctx, "folder1/folder2") + Expect(err).ToNot(HaveOccurred()) + Expect(len(ic.patternStack)).To(Equal(3)) // root, folder1 (empty), folder2 + Expect(ic.currentPatterns).To(ConsistOf("root.txt", "level2.txt")) + }) + }) + }) + + Describe("ShouldIgnore", func() { + var ic *IgnoreChecker + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + Context("with no patterns loaded", func() { + It("should not ignore any path", func() { + fsys := fstest.MapFS{} + ic = newIgnoreChecker(fsys) + Expect(ic.ShouldIgnore(ctx, "anything.txt")).To(BeFalse()) + Expect(ic.ShouldIgnore(ctx, "folder/file.mp3")).To(BeFalse()) + }) + }) + + Context("special paths", func() { + BeforeEach(func() { + fsys := fstest.MapFS{ + ".ndignore": &fstest.MapFile{Data: []byte("**/*")}, + } + ic = newIgnoreChecker(fsys) + err := ic.Push(ctx, ".") + Expect(err).ToNot(HaveOccurred()) + }) + + It("should never ignore root or empty paths", func() { + Expect(ic.ShouldIgnore(ctx, "")).To(BeFalse()) + Expect(ic.ShouldIgnore(ctx, ".")).To(BeFalse()) + }) + + It("should ignore all other paths with wildcard", func() { + Expect(ic.ShouldIgnore(ctx, "file.txt")).To(BeTrue()) + Expect(ic.ShouldIgnore(ctx, "folder/file.mp3")).To(BeTrue()) + }) + }) + + DescribeTable("pattern matching", + func(pattern string, path string, shouldMatch bool) { + fsys := fstest.MapFS{ + ".ndignore": &fstest.MapFile{Data: []byte(pattern)}, + } + ic = newIgnoreChecker(fsys) + err := ic.Push(ctx, ".") + Expect(err).ToNot(HaveOccurred()) + Expect(ic.ShouldIgnore(ctx, path)).To(Equal(shouldMatch)) + }, + Entry("glob match", "*.txt", "file.txt", true), + Entry("glob no match", "*.txt", "file.mp3", false), + Entry("directory pattern match", "tmp/", "tmp/file.txt", true), + Entry("directory pattern no match", "tmp/", "temporary/file.txt", false), + Entry("nested glob match", "**/*.log", "deep/nested/file.log", true), + Entry("nested glob no match", "**/*.log", "deep/nested/file.txt", false), + Entry("specific file match", "ignore.me", "ignore.me", true), + Entry("specific file no match", "ignore.me", "keep.me", false), + Entry("wildcard all", "**/*", "any/path/file.txt", true), + Entry("nested specific match", "temp/*", "temp/cache.db", true), + Entry("nested specific no match", "temp/*", "temporary/cache.db", false), + ) + + Context("with multiple patterns", func() { + BeforeEach(func() { + fsys := fstest.MapFS{ + ".ndignore": &fstest.MapFile{Data: []byte("*.txt\n*.log\ntemp/")}, + } + ic = newIgnoreChecker(fsys) + err := ic.Push(ctx, ".") + Expect(err).ToNot(HaveOccurred()) + }) + + It("should match any of the patterns", func() { + Expect(ic.ShouldIgnore(ctx, "file.txt")).To(BeTrue()) + Expect(ic.ShouldIgnore(ctx, "debug.log")).To(BeTrue()) + Expect(ic.ShouldIgnore(ctx, "temp/cache")).To(BeTrue()) + Expect(ic.ShouldIgnore(ctx, "music.mp3")).To(BeFalse()) + }) + }) + }) +}) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index e04f10c70..2f6b62b2d 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -26,58 +26,46 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer, libs []model.Library) *phaseFolders { +func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer) *phaseFolders { var jobs []*scanJob - var updatedLibs []model.Library - for _, lib := range libs { - if lib.LastScanStartedAt.IsZero() { - err := ds.Library(ctx).ScanBegin(lib.ID, state.fullScan) - if err != nil { - log.Error(ctx, "Scanner: Error updating last scan started at", "lib", lib.Name, err) - state.sendWarning(err.Error()) - continue - } - // Reload library to get updated state - l, err := ds.Library(ctx).Get(lib.ID) - if err != nil { - log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err) - state.sendWarning(err.Error()) - continue - } - lib = *l - } else { - log.Debug(ctx, "Scanner: Resuming previous scan", "lib", lib.Name, "lastScanStartedAt", lib.LastScanStartedAt, "fullScan", lib.FullScanInProgress) + + // Create scan jobs for all libraries + for _, lib := range state.libraries { + // Get target folders for this library if selective scan + var targetFolders []string + if state.isSelectiveScan() { + targetFolders = state.targets[lib.ID] } - job, err := newScanJob(ctx, ds, cw, lib, state.fullScan) + + job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders) if err != nil { log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) state.sendWarning(err.Error()) continue } jobs = append(jobs, job) - updatedLibs = append(updatedLibs, lib) } - // Update the state with the libraries that have been processed and have their scan timestamps set - state.libraries = updatedLibs - return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state} } type scanJob struct { - lib model.Library - fs storage.MusicFS - cw artwork.CacheWarmer - lastUpdates map[string]model.FolderUpdateInfo - lock sync.Mutex - numFolders atomic.Int64 + lib model.Library + fs storage.MusicFS + cw artwork.CacheWarmer + lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library + targetFolders []string // Specific folders to scan (including all descendants) + lock sync.Mutex + numFolders atomic.Int64 } -func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool) (*scanJob, error) { - lastUpdates, err := ds.Folder(ctx).GetLastUpdates(lib) +func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { + // Get folder updates, optionally filtered to specific target folders + lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(lib, targetFolders...) if err != nil { return nil, fmt.Errorf("getting last updates: %w", err) } + fileStore, err := storage.For(lib.Path) if err != nil { log.Error(ctx, "Error getting storage for library", "library", lib.Name, "path", lib.Path, err) @@ -88,15 +76,17 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, log.Error(ctx, "Error getting fs for library", "library", lib.Name, "path", lib.Path, err) return nil, fmt.Errorf("getting fs for library: %w", err) } - lib.FullScanInProgress = lib.FullScanInProgress || fullScan return &scanJob{ - lib: lib, - fs: fsys, - cw: cw, - lastUpdates: lastUpdates, + lib: lib, + fs: fsys, + cw: cw, + lastUpdates: lastUpdates, + targetFolders: targetFolders, }, nil } +// popLastUpdate retrieves and removes the last update info for the given folder ID +// This is used to track which folders have been found during the walk_dir_tree func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo { j.lock.Lock() defer j.lock.Unlock() @@ -106,6 +96,15 @@ func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo { return lastUpdate } +// createFolderEntry creates a new folderEntry for the given path, using the last update info from the job +// to populate the previous update time and hash. It also removes the folder from the job's lastUpdates map. +// This is used to track which folders have been found during the walk_dir_tree. +func (j *scanJob) createFolderEntry(path string) *folderEntry { + id := model.FolderID(j.lib, path) + info := j.popLastUpdate(id) + return newFolderEntry(j, id, path, info.UpdatedAt, info.Hash) +} + // phaseFolders represents the first phase of the scanning process, which is responsible // for scanning all libraries and importing new or updated files. This phase involves // traversing the directory tree of each library, identifying new or modified media files, @@ -144,7 +143,8 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { if utils.IsCtxDone(p.ctx) { break } - outputChan, err := walkDirTree(p.ctx, job) + + outputChan, err := walkDirTree(p.ctx, job, job.targetFolders...) if err != nil { log.Warn(p.ctx, "Scanner: Error scanning library", "lib", job.lib.Name, err) } diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index a6c0e261e..de93ed6ee 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -69,9 +69,6 @@ func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error { } } for _, lib := range p.state.libraries { - if lib.LastScanStartedAt.IsZero() { - continue - } log.Debug(p.ctx, "Scanner: Checking missing tracks", "libraryId", lib.ID, "libraryName", lib.Name) cursor, err := p.ds.MediaFile(p.ctx).GetMissingAndMatching(lib.ID) if err != nil { diff --git a/scanner/phase_3_refresh_albums.go b/scanner/phase_3_refresh_albums.go index f51aa8f4b..33e0fed01 100644 --- a/scanner/phase_3_refresh_albums.go +++ b/scanner/phase_3_refresh_albums.go @@ -27,14 +27,13 @@ import ( type phaseRefreshAlbums struct { ds model.DataStore ctx context.Context - libs model.Libraries refreshed atomic.Uint32 skipped atomic.Uint32 state *scanState } -func createPhaseRefreshAlbums(ctx context.Context, state *scanState, ds model.DataStore, libs model.Libraries) *phaseRefreshAlbums { - return &phaseRefreshAlbums{ctx: ctx, ds: ds, libs: libs, state: state} +func createPhaseRefreshAlbums(ctx context.Context, state *scanState, ds model.DataStore) *phaseRefreshAlbums { + return &phaseRefreshAlbums{ctx: ctx, ds: ds, state: state} } func (p *phaseRefreshAlbums) description() string { @@ -47,7 +46,7 @@ func (p *phaseRefreshAlbums) producer() ppl.Producer[*model.Album] { func (p *phaseRefreshAlbums) produce(put func(album *model.Album)) error { count := 0 - for _, lib := range p.libs { + for _, lib := range p.state.libraries { cursor, err := p.ds.Album(p.ctx).GetTouchedAlbums(lib.ID) if err != nil { return fmt.Errorf("loading touched albums: %w", err) diff --git a/scanner/phase_3_refresh_albums_test.go b/scanner/phase_3_refresh_albums_test.go index dea2556f0..1f0baf428 100644 --- a/scanner/phase_3_refresh_albums_test.go +++ b/scanner/phase_3_refresh_albums_test.go @@ -32,8 +32,8 @@ var _ = Describe("phaseRefreshAlbums", func() { {ID: 1, Name: "Library 1"}, {ID: 2, Name: "Library 2"}, } - state = &scanState{} - phase = createPhaseRefreshAlbums(ctx, state, ds, libs) + state = &scanState{libraries: libs} + phase = createPhaseRefreshAlbums(ctx, state, ds) }) Describe("description", func() { diff --git a/scanner/scanner.go b/scanner/scanner.go index 04a5c2456..20f3f5da8 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -3,6 +3,8 @@ package scanner import ( "context" "fmt" + "maps" + "slices" "sync/atomic" "time" @@ -15,6 +17,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/run" + "github.com/navidrome/navidrome/utils/slice" ) type scannerImpl struct { @@ -28,7 +31,8 @@ type scanState struct { progress chan<- *ProgressInfo fullScan bool changesDetected atomic.Bool - libraries model.Libraries // Store libraries list for consistency across phases + libraries model.Libraries // Store libraries list for consistency across phases + targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans } func (s *scanState) sendProgress(info *ProgressInfo) { @@ -37,6 +41,10 @@ func (s *scanState) sendProgress(info *ProgressInfo) { } } +func (s *scanState) isSelectiveScan() bool { + return len(s.targets) > 0 +} + func (s *scanState) sendWarning(msg string) { s.sendProgress(&ProgressInfo{Warning: msg}) } @@ -45,7 +53,7 @@ func (s *scanState) sendError(err error) { s.sendProgress(&ProgressInfo{Error: err.Error()}) } -func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo) { +func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { startTime := time.Now() state := scanState{ @@ -59,38 +67,75 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan< state.changesDetected.Store(true) } - libs, err := s.ds.Library(ctx).GetAll() + // Get libraries and optionally filter by targets + allLibs, err := s.ds.Library(ctx).GetAll() if err != nil { state.sendWarning(fmt.Sprintf("getting libraries: %s", err)) return } - state.libraries = libs - log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs)) + if len(targets) > 0 { + // Selective scan: filter libraries and build targets map + state.targets = make(map[int][]string) + + for _, target := range targets { + folderPath := target.FolderPath + if folderPath == "" { + folderPath = "." + } + state.targets[target.LibraryID] = append(state.targets[target.LibraryID], folderPath) + } + + // Filter libraries to only those in targets + state.libraries = slice.Filter(allLibs, func(lib model.Library) bool { + return len(state.targets[lib.ID]) > 0 + }) + + log.Info(ctx, "Scanner: Starting selective scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries), "numTargets", len(targets)) + } else { + // Full library scan + state.libraries = allLibs + log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries)) + } // Store scan type and start time scanType := "quick" if state.fullScan { scanType = "full" } + if state.isSelectiveScan() { + scanType += "-selective" + } _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType) _ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339)) // if there was a full scan in progress, force a full scan if !state.fullScan { - for _, lib := range libs { + for _, lib := range state.libraries { if lib.FullScanInProgress { log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name) state.fullScan = true - _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full") + if state.isSelectiveScan() { + _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full-selective") + } else { + _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full") + } break } } } + // Prepare libraries for scanning (initialize LastScanStartedAt if needed) + err = s.prepareLibrariesForScan(ctx, &state) + if err != nil { + log.Error(ctx, "Scanner: Error preparing libraries for scan", err) + state.sendError(err) + return + } + err = run.Sequentially( // Phase 1: Scan all libraries and import new/updated files - runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw, libs)), + runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw)), // Phase 2: Process missing files, checking for moves runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)), @@ -98,7 +143,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan< // Phases 3 and 4 can be run in parallel run.Parallel( // Phase 3: Refresh all new/changed albums and update artists - runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds, libs)), + runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds)), // Phase 4: Import/update playlists runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls, s.cw)), @@ -131,7 +176,53 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan< state.sendProgress(&ProgressInfo{ChangesDetected: true}) } - log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime)) + if state.isSelectiveScan() { + log.Info(ctx, "Scanner: Finished scanning selected folders", "duration", time.Since(startTime), "numTargets", len(targets)) + } else { + log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime)) + } +} + +// prepareLibrariesForScan initializes the scan for all libraries in the state. +// It calls ScanBegin for libraries that haven't started scanning yet (LastScanStartedAt is zero), +// reloads them to get the updated state, and filters out any libraries that fail to initialize. +func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanState) error { + var successfulLibs []model.Library + + for _, lib := range state.libraries { + if lib.LastScanStartedAt.IsZero() { + // This is a new scan - mark it as started + err := s.ds.Library(ctx).ScanBegin(lib.ID, state.fullScan) + if err != nil { + log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err) + state.sendWarning(err.Error()) + continue + } + + // Reload library to get updated state (timestamps, etc.) + reloadedLib, err := s.ds.Library(ctx).Get(lib.ID) + if err != nil { + log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err) + state.sendWarning(err.Error()) + continue + } + lib = *reloadedLib + } else { + // This is a resumed scan + log.Debug(ctx, "Scanner: Resuming previous scan", "lib", lib.Name, + "lastScanStartedAt", lib.LastScanStartedAt, "fullScan", lib.FullScanInProgress) + } + + successfulLibs = append(successfulLibs, lib) + } + + if len(successfulLibs) == 0 { + return fmt.Errorf("no libraries available for scanning") + } + + // Update state with only successfully initialized libraries + state.libraries = successfulLibs + return nil } func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error { @@ -140,7 +231,15 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error return s.ds.WithTx(func(tx model.DataStore) error { if state.changesDetected.Load() { start := time.Now() - err := tx.GC(ctx) + + // For selective scans, extract library IDs to scope GC operations + var libraryIDs []int + if state.isSelectiveScan() { + libraryIDs = slices.Collect(maps.Keys(state.targets)) + log.Debug(ctx, "Scanner: Running selective GC", "libraryIDs", libraryIDs) + } + + err := tx.GC(ctx, libraryIDs...) if err != nil { log.Error(ctx, "Scanner: Error running GC", err) return fmt.Errorf("running GC: %w", err) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index f27ad52fc..66db62edf 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -32,7 +32,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { var ctx context.Context var lib1, lib2 model.Library var ds *tests.MockDataStore - var s scanner.Scanner + var s model.Scanner createFS := func(path string, files fstest.MapFS) storagetest.FakeFS { fs := storagetest.FakeFS{} diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go new file mode 100644 index 000000000..629826db4 --- /dev/null +++ b/scanner/scanner_selective_test.go @@ -0,0 +1,293 @@ +package scanner_test + +import ( + "context" + "path/filepath" + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ScanFolders", Ordered, func() { + var ctx context.Context + var lib model.Library + var ds model.DataStore + var s model.Scanner + var fsys storagetest.FakeFS + + BeforeAll(func() { + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) + tmpDir := GinkgoT().TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "test-selective-scan.db?_journal_mode=WAL") + log.Warn("Using DB at " + conf.Server.DbPath) + db.Db().SetMaxOpenConns(1) + }) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + + db.Init(ctx) + DeferCleanup(func() { + Expect(tests.ClearDB()).To(Succeed()) + }) + + ds = persistence.New(db.Db()) + + // Create the admin user in the database to match the context + adminUser := model.User{ + ID: "123", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + NewPassword: "password", + } + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + + s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(ds), metrics.NewNoopInstance()) + + lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} + Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) + + // Initialize fake filesystem + fsys = storagetest.FakeFS{} + storagetest.Register("fake", &fsys) + }) + + Describe("Adding tracks to the library", func() { + It("scans specified folders recursively including all subdirectories", func() { + rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) + jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"}) + pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"}) + createFS(fstest.MapFS{ + "rock/track1.mp3": rock(track(1, "Rock Track 1")), + "rock/track2.mp3": rock(track(2, "Rock Track 2")), + "rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")), + "jazz/track4.mp3": jazz(track(1, "Jazz Track 1")), + "jazz/subdir/track5.mp3": jazz(track(2, "Jazz Track 2")), + "pop/track6.mp3": pop(track(1, "Pop Track 1")), + }) + + // Scan only the "rock" and "jazz" folders (including their subdirectories) + targets := []model.ScanTarget{ + {LibraryID: lib.ID, FolderPath: "rock"}, + {LibraryID: lib.ID, FolderPath: "jazz"}, + } + + warnings, err := s.ScanFolders(ctx, false, targets) + Expect(err).ToNot(HaveOccurred()) + Expect(warnings).To(BeEmpty()) + + // Verify all tracks in rock and jazz folders (including subdirectories) were imported + allFiles, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + + // Should have 5 tracks (all rock and jazz tracks including subdirectories) + Expect(allFiles).To(HaveLen(5)) + + // Get the file paths + paths := slice.Map(allFiles, func(mf model.MediaFile) string { + return filepath.ToSlash(mf.Path) + }) + + // Verify the correct files were scanned (including subdirectories) + Expect(paths).To(ContainElements( + "rock/track1.mp3", + "rock/track2.mp3", + "rock/subdir/track3.mp3", + "jazz/track4.mp3", + "jazz/subdir/track5.mp3", + )) + + // Verify files in the pop folder were NOT scanned + Expect(paths).ToNot(ContainElement("pop/track6.mp3")) + }) + }) + + Describe("Deleting folders", func() { + Context("when a child folder is deleted", func() { + var ( + revolver, help func(...map[string]any) *fstest.MapFile + artistFolderID string + album1FolderID string + album2FolderID string + album1TrackIDs []string + album2TrackIDs []string + ) + + BeforeEach(func() { + // Setup template functions for creating test files + revolver = storagetest.Template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) + help = storagetest.Template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965}) + + // Initial filesystem with nested folders + fsys.SetFiles(fstest.MapFS{ + "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")), + "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")), + "The Beatles/Help!/01 - Help!.mp3": help(storagetest.Track(1, "Help!")), + "The Beatles/Help!/02 - The Night Before.mp3": help(storagetest.Track(2, "The Night Before")), + }) + + // First scan - import everything + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Verify initial state - all folders exist + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) + Expect(err).ToNot(HaveOccurred()) + Expect(folders).To(HaveLen(4)) // root, Artist, Album1, Album2 + + // Store folder IDs for later verification + for _, f := range folders { + switch f.Name { + case "The Beatles": + artistFolderID = f.ID + case "Revolver": + album1FolderID = f.ID + case "Help!": + album2FolderID = f.ID + } + } + + // Verify all tracks exist + allTracks, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(allTracks).To(HaveLen(4)) + + // Store track IDs for later verification + for _, t := range allTracks { + if t.Album == "Revolver" { + album1TrackIDs = append(album1TrackIDs, t.ID) + } else if t.Album == "Help!" { + album2TrackIDs = append(album2TrackIDs, t.ID) + } + } + + // Verify no tracks are missing initially + for _, t := range allTracks { + Expect(t.Missing).To(BeFalse()) + } + }) + + It("should mark child folder and its tracks as missing when parent is scanned", func() { + // Delete the child folder (Help!) from the filesystem + fsys.SetFiles(fstest.MapFS{ + "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")), + "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")), + // "The Beatles/Help!" folder and its contents are DELETED + }) + + // Run selective scan on the parent folder (Artist) + // This simulates what the watcher does when a child folder is deleted + _, err := s.ScanFolders(ctx, false, []model.ScanTarget{ + {LibraryID: lib.ID, FolderPath: "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + + // Verify the deleted child folder is now marked as missing + deletedFolder, err := ds.Folder(ctx).Get(album2FolderID) + Expect(err).ToNot(HaveOccurred()) + Expect(deletedFolder.Missing).To(BeTrue(), "Deleted child folder should be marked as missing") + + // Verify the deleted folder's tracks are marked as missing + for _, trackID := range album2TrackIDs { + track, err := ds.MediaFile(ctx).Get(trackID) + Expect(err).ToNot(HaveOccurred()) + Expect(track.Missing).To(BeTrue(), "Track in deleted folder should be marked as missing") + } + + // Verify the parent folder is still present and not marked as missing + parentFolder, err := ds.Folder(ctx).Get(artistFolderID) + Expect(err).ToNot(HaveOccurred()) + Expect(parentFolder.Missing).To(BeFalse(), "Parent folder should not be marked as missing") + + // Verify the sibling folder and its tracks are still present and not missing + siblingFolder, err := ds.Folder(ctx).Get(album1FolderID) + Expect(err).ToNot(HaveOccurred()) + Expect(siblingFolder.Missing).To(BeFalse(), "Sibling folder should not be marked as missing") + + for _, trackID := range album1TrackIDs { + track, err := ds.MediaFile(ctx).Get(trackID) + Expect(err).ToNot(HaveOccurred()) + Expect(track.Missing).To(BeFalse(), "Track in sibling folder should not be marked as missing") + } + }) + + It("should mark deeply nested child folders as missing", func() { + // Add a deeply nested folder structure + fsys.SetFiles(fstest.MapFS{ + "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")), + "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")), + "The Beatles/Help!/01 - Help!.mp3": help(storagetest.Track(1, "Help!")), + "The Beatles/Help!/02 - The Night Before.mp3": help(storagetest.Track(2, "The Night Before")), + "The Beatles/Help!/Bonus/01 - Bonus Track.mp3": help(storagetest.Track(99, "Bonus Track")), + "The Beatles/Help!/Bonus/Nested/01 - Deep Track.mp3": help(storagetest.Track(100, "Deep Track")), + }) + + // Rescan to import the new nested structure + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Verify nested folders were created + allFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(allFolders)).To(BeNumerically(">", 4), "Should have more folders with nested structure") + + // Now delete the entire Help! folder including nested children + fsys.SetFiles(fstest.MapFS{ + "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")), + "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")), + // All Help! subfolders are deleted + }) + + // Run selective scan on parent + _, err = s.ScanFolders(ctx, false, []model.ScanTarget{ + {LibraryID: lib.ID, FolderPath: "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + + // Verify all Help! folders (including nested ones) are marked as missing + missingFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"library_id": lib.ID}, + squirrel.Eq{"missing": true}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(len(missingFolders)).To(BeNumerically(">", 0), "At least one folder should be marked as missing") + + // Verify all tracks in deleted folders are marked as missing + allTracks, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(allTracks).To(HaveLen(6)) + + for _, track := range allTracks { + if track.Album == "Help!" { + Expect(track.Missing).To(BeTrue(), "All tracks in deleted Help! folder should be marked as missing") + } else if track.Album == "Revolver" { + Expect(track.Missing).To(BeFalse(), "Tracks in Revolver folder should not be marked as missing") + } + } + }) + }) + }) +}) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index e7e354f21..873065aa3 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -34,19 +34,19 @@ type _t = map[string]any var template = storagetest.Template var track = storagetest.Track +func createFS(files fstest.MapFS) storagetest.FakeFS { + fs := storagetest.FakeFS{} + fs.SetFiles(files) + storagetest.Register("fake", &fs) + return fs +} + var _ = Describe("Scanner", Ordered, func() { var ctx context.Context var lib model.Library var ds *tests.MockDataStore var mfRepo *mockMediaFileRepo - var s scanner.Scanner - - createFS := func(files fstest.MapFS) storagetest.FakeFS { - fs := storagetest.FakeFS{} - fs.SetFiles(files) - storagetest.Register("fake", &fs) - return fs - } + var s model.Scanner BeforeAll(func() { ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) @@ -478,6 +478,56 @@ var _ = Describe("Scanner", Ordered, func() { Expect(mf.Missing).To(BeFalse()) }) + It("marks tracks as missing when scanning a deleted folder with ScanFolders", func() { + By("Adding a third track to Revolver to have more test data") + fsys.Add("The Beatles/Revolver/03 - I'm Only Sleeping.mp3", revolver(track(3, "I'm Only Sleeping"))) + Expect(runScanner(ctx, false)).To(Succeed()) + + By("Verifying initial state has 5 tracks") + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"missing": false}, + })).To(Equal(int64(5))) + + By("Removing the entire Revolver folder from filesystem") + fsys.Remove("The Beatles/Revolver/01 - Taxman.mp3") + fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") + fsys.Remove("The Beatles/Revolver/03 - I'm Only Sleeping.mp3") + + By("Scanning the parent folder (simulating watcher behavior)") + targets := []model.ScanTarget{ + {LibraryID: lib.ID, FolderPath: "The Beatles"}, + } + _, err := s.ScanFolders(ctx, false, targets) + Expect(err).To(Succeed()) + + By("Checking all Revolver tracks are marked as missing") + mf, err := findByPath("The Beatles/Revolver/01 - Taxman.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(mf.Missing).To(BeTrue()) + + mf, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(mf.Missing).To(BeTrue()) + + mf, err = findByPath("The Beatles/Revolver/03 - I'm Only Sleeping.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(mf.Missing).To(BeTrue()) + + By("Checking the Help! tracks are not affected") + mf, err = findByPath("The Beatles/Help!/01 - Help!.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(mf.Missing).To(BeFalse()) + + mf, err = findByPath("The Beatles/Help!/02 - The Night Before.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(mf.Missing).To(BeFalse()) + + By("Verifying only 2 non-missing tracks remain (Help! tracks)") + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"missing": false}, + })).To(Equal(int64(2))) + }) + It("does not override artist fields when importing an undertagged file", func() { By("Making sure artist in the DB contains MBID and sort name") aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{ diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 63854d262..e6a694f2b 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -1,7 +1,6 @@ package scanner import ( - "bufio" "context" "io/fs" "maps" @@ -11,37 +10,69 @@ import ( "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" - ignore "github.com/sabhiram/go-gitignore" ) -func walkDirTree(ctx context.Context, job *scanJob) (<-chan *folderEntry, error) { +// walkDirTree recursively walks the directory tree starting from the given targetFolders. +// If no targetFolders are provided, it starts from the root folder ("."). +// It returns a channel of folderEntry pointers representing each folder found. +func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-chan *folderEntry, error) { results := make(chan *folderEntry) + folders := targetFolders + if len(targetFolders) == 0 { + // No specific folders provided, scan the root folder + folders = []string{"."} + } go func() { defer close(results) - err := walkFolder(ctx, job, ".", nil, results) - if err != nil { - log.Error(ctx, "Scanner: There were errors reading directories from filesystem", "path", job.lib.Path, err) - return + for _, folderPath := range folders { + if utils.IsCtxDone(ctx) { + return + } + + // Check if target folder exists before walking it + // If it doesn't exist (e.g., deleted between watcher detection and scan execution), + // skip it so it remains in job.lastUpdates and gets handled in following steps + _, err := fs.Stat(job.fs, folderPath) + if err != nil { + log.Warn(ctx, "Scanner: Target folder does not exist.", "path", folderPath, err) + continue + } + + // Create checker and push patterns from root to this folder + checker := newIgnoreChecker(job.fs) + err = checker.PushAllParents(ctx, folderPath) + if err != nil { + log.Error(ctx, "Scanner: Error pushing ignore patterns for target folder", "path", folderPath, err) + continue + } + + // Recursively walk this folder and all its children + err = walkFolder(ctx, job, folderPath, checker, results) + if err != nil { + log.Error(ctx, "Scanner: Error walking target folder", "path", folderPath, err) + continue + } } - log.Debug(ctx, "Scanner: Finished reading folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load()) + log.Debug(ctx, "Scanner: Finished reading target folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load()) }() return results, nil } -func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignorePatterns []string, results chan<- *folderEntry) error { - ignorePatterns = loadIgnoredPatterns(ctx, job.fs, currentFolder, ignorePatterns) +func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker *IgnoreChecker, results chan<- *folderEntry) error { + // Push patterns for this folder onto the stack + _ = checker.Push(ctx, currentFolder) + defer checker.Pop() // Pop patterns when leaving this folder - folder, children, err := loadDir(ctx, job, currentFolder, ignorePatterns) + folder, children, err := loadDir(ctx, job, currentFolder, checker) if err != nil { log.Warn(ctx, "Scanner: Error loading dir. Skipping", "path", currentFolder, err) return nil } for _, c := range children { - err := walkFolder(ctx, job, c, ignorePatterns, results) + err := walkFolder(ctx, job, c, checker, results) if err != nil { return err } @@ -59,50 +90,17 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignoreP return nil } -func loadIgnoredPatterns(ctx context.Context, fsys fs.FS, currentFolder string, currentPatterns []string) []string { - ignoreFilePath := path.Join(currentFolder, consts.ScanIgnoreFile) - var newPatterns []string - if _, err := fs.Stat(fsys, ignoreFilePath); err == nil { - // Read and parse the .ndignore file - ignoreFile, err := fsys.Open(ignoreFilePath) - if err != nil { - log.Warn(ctx, "Scanner: Error opening .ndignore file", "path", ignoreFilePath, err) - // Continue with previous patterns - } else { - defer ignoreFile.Close() - scanner := bufio.NewScanner(ignoreFile) - for scanner.Scan() { - line := scanner.Text() - if line == "" || strings.HasPrefix(line, "#") { - continue // Skip empty lines and comments - } - newPatterns = append(newPatterns, line) - } - if err := scanner.Err(); err != nil { - log.Warn(ctx, "Scanner: Error reading .ignore file", "path", ignoreFilePath, err) - } - } - // If the .ndignore file is empty, mimic the current behavior and ignore everything - if len(newPatterns) == 0 { - log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", currentFolder) - newPatterns = []string{"**/*"} - } else { - log.Trace(ctx, "Scanner: .ndignore file found ", "path", ignoreFilePath, "patterns", newPatterns) - } - } - // Combine the patterns from the .ndignore file with the ones passed as argument - combinedPatterns := append([]string{}, currentPatterns...) - return append(combinedPatterns, newPatterns...) -} - -func loadDir(ctx context.Context, job *scanJob, dirPath string, ignorePatterns []string) (folder *folderEntry, children []string, err error) { - folder = newFolderEntry(job, dirPath) - +func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) { + // Check if directory exists before creating the folder entry + // This is important to avoid removing the folder from lastUpdates if it doesn't exist dirInfo, err := fs.Stat(job.fs, dirPath) if err != nil { log.Warn(ctx, "Scanner: Error stating dir", "path", dirPath, err) return nil, nil, err } + + // Now that we know the folder exists, create the entry (which removes it from lastUpdates) + folder = job.createFolderEntry(dirPath) folder.modTime = dirInfo.ModTime() dir, err := job.fs.Open(dirPath) @@ -117,12 +115,11 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, ignorePatterns [ return folder, children, err } - ignoreMatcher := ignore.CompileIgnoreLines(ignorePatterns...) entries := fullReadDir(ctx, dirFile) children = make([]string, 0, len(entries)) for _, entry := range entries { entryPath := path.Join(dirPath, entry.Name()) - if len(ignorePatterns) > 0 && isScanIgnored(ctx, ignoreMatcher, entryPath) { + if checker.ShouldIgnore(ctx, entryPath) { log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath) continue } @@ -234,6 +231,7 @@ func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool { var ignoredDirs = []string{ "$RECYCLE.BIN", "#snapshot", + "@Recycle", "@Recently-Snapshot", ".streams", "lost+found", @@ -254,11 +252,3 @@ func isDirIgnored(name string) bool { func isEntryIgnored(name string) bool { return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") } - -func isScanIgnored(ctx context.Context, matcher *ignore.GitIgnore, entryPath string) bool { - matches := matcher.MatchesPath(entryPath) - if matches { - log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore: ", "path", entryPath) - } - return matches -} diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 1cab8a0b7..c9add0bd1 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -25,82 +25,196 @@ var _ = Describe("walk_dir_tree", func() { ctx context.Context ) - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = GinkgoT().Context() - fsys = &mockMusicFS{ - FS: fstest.MapFS{ - "root/a/.ndignore": {Data: []byte("ignored/*")}, - "root/a/f1.mp3": {}, - "root/a/f2.mp3": {}, - "root/a/ignored/bad.mp3": {}, - "root/b/cover.jpg": {}, - "root/c/f3": {}, - "root/d": {}, - "root/d/.ndignore": {}, - "root/d/f1.mp3": {}, - "root/d/f2.mp3": {}, - "root/d/f3.mp3": {}, - "root/e/original/f1.mp3": {}, - "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")}, + Context("full library", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = GinkgoT().Context() + fsys = &mockMusicFS{ + FS: fstest.MapFS{ + "root/a/.ndignore": {Data: []byte("ignored/*")}, + "root/a/f1.mp3": {}, + "root/a/f2.mp3": {}, + "root/a/ignored/bad.mp3": {}, + "root/b/cover.jpg": {}, + "root/c/f3": {}, + "root/d": {}, + "root/d/.ndignore": {}, + "root/d/f1.mp3": {}, + "root/d/f2.mp3": {}, + "root/d/f3.mp3": {}, + "root/e/original/f1.mp3": {}, + "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")}, + }, + } + job = &scanJob{ + fs: fsys, + lib: model.Library{Path: "/music"}, + } + }) + + // 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", + func(followSymlinks bool, expectedFolderCount int) { + conf.Server.Scanner.FollowSymlinks = followSymlinks + folders := getFolders() + + Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root` + + // Basic folder structure checks + Expect(folders["root/a"].audioFiles).To(SatisfyAll( + HaveLen(2), + HaveKey("f1.mp3"), + HaveKey("f2.mp3"), + )) + Expect(folders["root/a"].imageFiles).To(BeEmpty()) + Expect(folders["root/b"].audioFiles).To(BeEmpty()) + Expect(folders["root/b"].imageFiles).To(SatisfyAll( + HaveLen(1), + HaveKey("cover.jpg"), + )) + Expect(folders["root/c"].audioFiles).To(BeEmpty()) + Expect(folders["root/c"].imageFiles).To(BeEmpty()) + Expect(folders).ToNot(HaveKey("root/d")) + + // Symlink specific checks + if followSymlinks { + Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1)) + } else { + Expect(folders).ToNot(HaveKey("root/e/symlink")) + } }, - } - job = &scanJob{ - fs: fsys, - lib: model.Library{Path: "/music"}, - } + Entry("with symlinks enabled", true, 7), + Entry("with symlinks disabled", false, 6), + ) }) - // 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 + Context("with target folders", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = GinkgoT().Context() + fsys = &mockMusicFS{ + FS: fstest.MapFS{ + "Artist/Album1/track1.mp3": {}, + "Artist/Album1/track2.mp3": {}, + "Artist/Album2/track1.mp3": {}, + "Artist/Album2/track2.mp3": {}, + "Artist/Album2/Sub/track3.mp3": {}, + "OtherArtist/Album3/track1.mp3": {}, + }, + } + job = &scanJob{ + fs: fsys, + lib: model.Library{Path: "/music"}, } - return nil }) - _ = g.Wait() - return folders - } - DescribeTable("symlink handling", - func(followSymlinks bool, expectedFolderCount int) { - conf.Server.Scanner.FollowSymlinks = followSymlinks - folders := getFolders() + It("should recursively walk all subdirectories of target folders", func() { + results, err := walkDirTree(ctx, job, "Artist") + Expect(err).ToNot(HaveOccurred()) - Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root` + folders := map[string]*folderEntry{} + g := errgroup.Group{} + g.Go(func() error { + for folder := range results { + folders[folder.path] = folder + } + return nil + }) + _ = g.Wait() - // Basic folder structure checks - Expect(folders["root/a"].audioFiles).To(SatisfyAll( - HaveLen(2), - HaveKey("f1.mp3"), - HaveKey("f2.mp3"), + // Should include the target folder and all its descendants + Expect(folders).To(SatisfyAll( + HaveKey("Artist"), + HaveKey("Artist/Album1"), + HaveKey("Artist/Album2"), + HaveKey("Artist/Album2/Sub"), )) - Expect(folders["root/a"].imageFiles).To(BeEmpty()) - Expect(folders["root/b"].audioFiles).To(BeEmpty()) - Expect(folders["root/b"].imageFiles).To(SatisfyAll( - HaveLen(1), - HaveKey("cover.jpg"), - )) - Expect(folders["root/c"].audioFiles).To(BeEmpty()) - Expect(folders["root/c"].imageFiles).To(BeEmpty()) - Expect(folders).ToNot(HaveKey("root/d")) - // Symlink specific checks - if followSymlinks { - Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1)) - } else { - Expect(folders).ToNot(HaveKey("root/e/symlink")) + // Should not include folders outside the target + Expect(folders).ToNot(HaveKey("OtherArtist")) + Expect(folders).ToNot(HaveKey("OtherArtist/Album3")) + + // Verify audio files are present + Expect(folders["Artist/Album1"].audioFiles).To(HaveLen(2)) + Expect(folders["Artist/Album2"].audioFiles).To(HaveLen(2)) + Expect(folders["Artist/Album2/Sub"].audioFiles).To(HaveLen(1)) + }) + + It("should handle multiple target folders", func() { + results, err := walkDirTree(ctx, job, "Artist/Album1", "OtherArtist") + 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() + + // Should include both target folders and their descendants + Expect(folders).To(SatisfyAll( + HaveKey("Artist/Album1"), + HaveKey("OtherArtist"), + HaveKey("OtherArtist/Album3"), + )) + + // Should not include other folders + Expect(folders).ToNot(HaveKey("Artist")) + Expect(folders).ToNot(HaveKey("Artist/Album2")) + Expect(folders).ToNot(HaveKey("Artist/Album2/Sub")) + }) + + It("should skip non-existent target folders and preserve them in lastUpdates", func() { + // Setup job with lastUpdates for both existing and non-existing folders + job.lastUpdates = map[string]model.FolderUpdateInfo{ + model.FolderID(job.lib, "Artist/Album1"): {}, + model.FolderID(job.lib, "NonExistent/DeletedFolder"): {}, + model.FolderID(job.lib, "OtherArtist/Album3"): {}, } - }, - Entry("with symlinks enabled", true, 7), - Entry("with symlinks disabled", false, 6), - ) + + // Try to scan existing folder and non-existing folder + results, err := walkDirTree(ctx, job, "Artist/Album1", "NonExistent/DeletedFolder") + Expect(err).ToNot(HaveOccurred()) + + // Collect results + folders := map[string]struct{}{} + for folder := range results { + folders[folder.path] = struct{}{} + } + + // Should only include the existing folder + Expect(folders).To(HaveKey("Artist/Album1")) + Expect(folders).ToNot(HaveKey("NonExistent/DeletedFolder")) + + // The non-existent folder should still be in lastUpdates (not removed by popLastUpdate) + Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "NonExistent/DeletedFolder"))) + + // The existing folder should have been removed from lastUpdates + Expect(job.lastUpdates).ToNot(HaveKey(model.FolderID(job.lib, "Artist/Album1"))) + + // Folders not in targets should remain in lastUpdates + Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3"))) + }) + }) }) Describe("helper functions", func() { diff --git a/scanner/watcher.go b/scanner/watcher.go index 37cfb5e22..ad9a06421 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -24,9 +24,9 @@ type Watcher interface { type watcher struct { mainCtx context.Context ds model.DataStore - scanner Scanner + scanner model.Scanner triggerWait time.Duration - watcherNotify chan model.Library + watcherNotify chan scanNotification libraryWatchers map[int]*libraryWatcherInstance mu sync.RWMutex } @@ -36,14 +36,19 @@ type libraryWatcherInstance struct { cancel context.CancelFunc } +type scanNotification struct { + Library *model.Library + FolderPath string +} + // GetWatcher returns the watcher singleton -func GetWatcher(ds model.DataStore, s Scanner) Watcher { +func GetWatcher(ds model.DataStore, s model.Scanner) Watcher { return singleton.GetInstance(func() *watcher { return &watcher{ ds: ds, scanner: s, triggerWait: conf.Server.Scanner.WatcherWait, - watcherNotify: make(chan model.Library, 1), + watcherNotify: make(chan scanNotification, 1), libraryWatchers: make(map[int]*libraryWatcherInstance), } }) @@ -68,11 +73,11 @@ func (w *watcher) Run(ctx context.Context) error { // Main scan triggering loop trigger := time.NewTimer(w.triggerWait) trigger.Stop() - waiting := false + targets := make(map[model.ScanTarget]struct{}) for { select { case <-trigger.C: - log.Info("Watcher: Triggering scan") + log.Info("Watcher: Triggering scan for changed folders", "numTargets", len(targets)) status, err := w.scanner.Status(ctx) if err != nil { log.Error(ctx, "Watcher: Error retrieving Scanner status", err) @@ -83,9 +88,23 @@ func (w *watcher) Run(ctx context.Context) error { trigger.Reset(w.triggerWait * 3) continue } - waiting = false + + // Convert targets map to slice + targetSlice := make([]model.ScanTarget, 0, len(targets)) + for target := range targets { + targetSlice = append(targetSlice, target) + } + + // Clear targets for next batch + targets = make(map[model.ScanTarget]struct{}) + go func() { - _, err := w.scanner.ScanAll(ctx, false) + var err error + if conf.Server.DevSelectiveWatcher { + _, err = w.scanner.ScanFolders(ctx, false, targetSlice) + } else { + _, err = w.scanner.ScanAll(ctx, false) + } if err != nil { log.Error(ctx, "Watcher: Error scanning", err) } else { @@ -102,13 +121,20 @@ func (w *watcher) Run(ctx context.Context) error { w.libraryWatchers = make(map[int]*libraryWatcherInstance) w.mu.Unlock() return nil - case lib := <-w.watcherNotify: - if !waiting { - log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan", - "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) - waiting = true + case notification := <-w.watcherNotify: + lib := notification.Library + folderPath := notification.FolderPath + + // If already scheduled for scan, skip + target := model.ScanTarget{LibraryID: lib.ID, FolderPath: folderPath} + if _, exists := targets[target]; exists { + continue } + targets[target] = struct{}{} trigger.Reset(w.triggerWait) + + log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan", + "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "folderPath", folderPath) } } } @@ -199,13 +225,18 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error { log.Info(ctx, "Watcher started for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "absoluteLibPath", absLibPath) + return w.processLibraryEvents(ctx, lib, fsys, c, absLibPath) +} + +// processLibraryEvents processes filesystem events for a library. +func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library, fsys storage.MusicFS, events <-chan string, absLibPath string) error { for { select { case <-ctx.Done(): log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name) return nil - case path := <-c: - path, err = filepath.Rel(absLibPath, path) + case path := <-events: + path, err := filepath.Rel(absLibPath, path) if err != nil { log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err) continue @@ -215,12 +246,27 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error { log.Trace(ctx, "Ignoring change", "libraryID", lib.ID, "path", path) continue } - log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", path, "absoluteLibPath", absLibPath) + // Check if the original path (before resolution) matches .ndignore patterns + // This is crucial for deleted folders - if a deleted folder matches .ndignore, + // we should ignore it BEFORE resolveFolderPath walks up to the parent + if w.shouldIgnoreFolderPath(ctx, fsys, path) { + log.Debug(ctx, "Ignoring change matching .ndignore pattern", "libraryID", lib.ID, "path", path) + continue + } + + // Find the folder to scan - validate path exists as directory, walk up if needed + folderPath := resolveFolderPath(fsys, path) + // Double-check after resolution in case the resolved path is different and also matches patterns + if folderPath != path && w.shouldIgnoreFolderPath(ctx, fsys, folderPath) { + log.Trace(ctx, "Ignoring change in folder matching .ndignore pattern", "libraryID", lib.ID, "folderPath", folderPath) + continue + } + // Notify the main watcher of changes select { - case w.watcherNotify <- *lib: + case w.watcherNotify <- scanNotification{Library: lib, FolderPath: folderPath}: default: // Channel is full, notification already pending } @@ -228,6 +274,47 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error { } } +// resolveFolderPath takes a path (which may be a file or directory) and returns +// the folder path to scan. If the path is a file, it walks up to find the parent +// directory. Returns empty string if the path should scan the library root. +func resolveFolderPath(fsys fs.FS, path string) string { + // Handle root paths immediately + if path == "." || path == "" { + return "" + } + + folderPath := path + for { + info, err := fs.Stat(fsys, folderPath) + if err == nil && info.IsDir() { + // Found a valid directory + return folderPath + } + if folderPath == "." || folderPath == "" { + // Reached root, scan entire library + return "" + } + // Walk up the tree + dir, _ := filepath.Split(folderPath) + if dir == "" || dir == "." { + return "" + } + // Remove trailing slash + folderPath = filepath.Clean(dir) + } +} + +// shouldIgnoreFolderPath checks if the given folderPath should be ignored based on .ndignore patterns +// in the library. It pushes all parent folders onto the IgnoreChecker stack before checking. +func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.MusicFS, folderPath string) bool { + checker := newIgnoreChecker(fsys) + err := checker.PushAllParents(ctx, folderPath) + if err != nil { + log.Warn(ctx, "Watcher: Error pushing ignore patterns for folder", "path", folderPath, err) + } + return checker.ShouldIgnore(ctx, folderPath) +} + func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool { baseDir, name := filepath.Split(path) switch { diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go new file mode 100644 index 000000000..01bfb2491 --- /dev/null +++ b/scanner/watcher_test.go @@ -0,0 +1,491 @@ +package scanner + +import ( + "context" + "io/fs" + "path/filepath" + "testing/fstest" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Watcher", func() { + var ctx context.Context + var cancel context.CancelFunc + var mockScanner *tests.MockScanner + var mockDS *tests.MockDataStore + var w *watcher + var lib *model.Library + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Scanner.WatcherWait = 50 * time.Millisecond // Short wait for tests + + ctx, cancel = context.WithCancel(context.Background()) + DeferCleanup(cancel) + + lib = &model.Library{ + ID: 1, + Name: "Test Library", + Path: "/test/library", + } + + // Set up mocks + mockScanner = tests.NewMockScanner() + mockDS = &tests.MockDataStore{} + mockLibRepo := &tests.MockLibraryRepo{} + mockLibRepo.SetData(model.Libraries{*lib}) + mockDS.MockedLibrary = mockLibRepo + + // Create a new watcher instance (not singleton) for testing + w = &watcher{ + ds: mockDS, + scanner: mockScanner, + triggerWait: conf.Server.Scanner.WatcherWait, + watcherNotify: make(chan scanNotification, 10), + libraryWatchers: make(map[int]*libraryWatcherInstance), + mainCtx: ctx, + } + }) + + Describe("Target Collection and Deduplication", func() { + BeforeEach(func() { + // Start watcher in background + go func() { + _ = w.Run(ctx) + }() + + // Give watcher time to initialize + time.Sleep(10 * time.Millisecond) + }) + + It("creates separate targets for different folders", func() { + // Send notifications for different folders + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} + time.Sleep(10 * time.Millisecond) + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"} + + // Wait for watcher to process and trigger scan + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + + // Verify two targets + calls := mockScanner.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].Targets).To(HaveLen(2)) + + // Extract folder paths + folderPaths := make(map[string]bool) + for _, target := range calls[0].Targets { + Expect(target.LibraryID).To(Equal(1)) + folderPaths[target.FolderPath] = true + } + Expect(folderPaths).To(HaveKey("artist1")) + Expect(folderPaths).To(HaveKey("artist2")) + }) + + It("handles different folder paths correctly", func() { + // Send notification for nested folder + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} + + // Wait for watcher to process and trigger scan + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + + // Verify the target + calls := mockScanner.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].Targets).To(HaveLen(1)) + Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1")) + }) + + It("deduplicates folder and file within same folder", func() { + // Send notification for a folder + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} + time.Sleep(10 * time.Millisecond) + // Send notification for same folder (as if file change was detected there) + // In practice, watchLibrary() would walk up from file path to folder + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} + time.Sleep(10 * time.Millisecond) + // Send another for same folder + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} + + // Wait for watcher to process and trigger scan + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + + // Verify only one target despite multiple file/folder changes + calls := mockScanner.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].Targets).To(HaveLen(1)) + Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1")) + }) + }) + + Describe("Timer Behavior", func() { + BeforeEach(func() { + // Start watcher in background + go func() { + _ = w.Run(ctx) + }() + + // Give watcher time to initialize + time.Sleep(10 * time.Millisecond) + }) + + It("resets timer on each change (debouncing)", func() { + // Send first notification + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} + + // Wait a bit less than half the watcher wait time to ensure timer doesn't fire + time.Sleep(20 * time.Millisecond) + + // No scan should have been triggered yet + Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) + + // Send another notification (resets timer) + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} + + // Wait a bit less than half the watcher wait time again + time.Sleep(20 * time.Millisecond) + + // Still no scan + Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) + + // Wait for full timer to expire after last notification (plus margin) + time.Sleep(60 * time.Millisecond) + + // Now scan should have been triggered + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 100*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }) + + It("triggers scan after quiet period", func() { + // Send notification + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} + + // No scan immediately + Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) + + // Wait for quiet period + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }) + }) + + Describe("Empty and Root Paths", func() { + BeforeEach(func() { + // Start watcher in background + go func() { + _ = w.Run(ctx) + }() + + // Give watcher time to initialize + time.Sleep(10 * time.Millisecond) + }) + + It("handles empty folder path (library root)", func() { + // Send notification with empty folder path + w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} + + // Wait for scan + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + + // Should scan the library root + calls := mockScanner.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].Targets).To(HaveLen(1)) + Expect(calls[0].Targets[0].FolderPath).To(Equal("")) + }) + + It("deduplicates empty and dot paths", func() { + // Send notifications with empty and dot paths + w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} + time.Sleep(10 * time.Millisecond) + w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} + + // Wait for scan + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + + // Should have only one target + calls := mockScanner.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].Targets).To(HaveLen(1)) + }) + }) + + Describe("Multiple Libraries", func() { + var lib2 *model.Library + + BeforeEach(func() { + // Create second library + lib2 = &model.Library{ + ID: 2, + Name: "Test Library 2", + Path: "/test/library2", + } + + mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo) + mockLibRepo.SetData(model.Libraries{*lib, *lib2}) + + // Start watcher in background + go func() { + _ = w.Run(ctx) + }() + + // Give watcher time to initialize + time.Sleep(10 * time.Millisecond) + }) + + It("creates separate targets for different libraries", func() { + // Send notifications for both libraries + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} + time.Sleep(10 * time.Millisecond) + w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"} + + // Wait for scan + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + + // Verify two targets for different libraries + calls := mockScanner.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].Targets).To(HaveLen(2)) + + // Verify library IDs are different + libraryIDs := make(map[int]bool) + for _, target := range calls[0].Targets { + libraryIDs[target.LibraryID] = true + } + Expect(libraryIDs).To(HaveKey(1)) + Expect(libraryIDs).To(HaveKey(2)) + }) + }) + + Describe(".ndignore handling", func() { + var ctx context.Context + var cancel context.CancelFunc + var w *watcher + var mockFS *mockMusicFS + var lib *model.Library + var eventChan chan string + var absLibPath string + + BeforeEach(func() { + ctx, cancel = context.WithCancel(GinkgoT().Context()) + DeferCleanup(cancel) + + // Set up library + var err error + absLibPath, err = filepath.Abs(".") + Expect(err).NotTo(HaveOccurred()) + + lib = &model.Library{ + ID: 1, + Name: "Test Library", + Path: absLibPath, + } + + // Create watcher with notification channel + w = &watcher{ + watcherNotify: make(chan scanNotification, 10), + } + + eventChan = make(chan string, 10) + }) + + // Helper to send an event - converts relative path to absolute + sendEvent := func(relativePath string) { + path := filepath.Join(absLibPath, relativePath) + eventChan <- path + } + + // Helper to start the real event processing loop + startEventProcessing := func() { + go func() { + defer GinkgoRecover() + // Call the actual processLibraryEvents method - testing the real implementation! + _ = w.processLibraryEvents(ctx, lib, mockFS, eventChan, absLibPath) + }() + } + + Context("when a folder matching .ndignore is deleted", func() { + BeforeEach(func() { + // Create filesystem with .ndignore containing _TEMP pattern + // The deleted folder (_TEMP) will NOT exist in the filesystem + mockFS = &mockMusicFS{ + FS: fstest.MapFS{ + "rock": &fstest.MapFile{Mode: fs.ModeDir}, + "rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")}, + "rock/valid_album": &fstest.MapFile{Mode: fs.ModeDir}, + "rock/valid_album/track.mp3": &fstest.MapFile{Data: []byte("audio")}, + }, + } + }) + + It("should NOT send scan notification when deleted folder matches .ndignore", func() { + startEventProcessing() + + // Simulate deletion event for rock/_TEMP + sendEvent("rock/_TEMP") + + // Wait a bit to ensure event is processed + time.Sleep(50 * time.Millisecond) + + // No notification should have been sent + Consistently(eventChan, 100*time.Millisecond).Should(BeEmpty()) + }) + + It("should send scan notification for valid folder deletion", func() { + startEventProcessing() + + // Simulate deletion event for rock/other_folder (not in .ndignore and doesn't exist) + // Since it doesn't exist in mockFS, resolveFolderPath will walk up to "rock" + sendEvent("rock/other_folder") + + // Should receive notification for parent folder + Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{ + Library: lib, + FolderPath: "rock", + }))) + }) + }) + + Context("with nested folder patterns", func() { + BeforeEach(func() { + mockFS = &mockMusicFS{ + FS: fstest.MapFS{ + "music": &fstest.MapFile{Mode: fs.ModeDir}, + "music/.ndignore": &fstest.MapFile{Data: []byte("**/temp\n**/cache\n")}, + "music/rock": &fstest.MapFile{Mode: fs.ModeDir}, + "music/rock/artist": &fstest.MapFile{Mode: fs.ModeDir}, + }, + } + }) + + It("should NOT send notification when nested ignored folder is deleted", func() { + startEventProcessing() + + // Simulate deletion of music/rock/artist/temp (matches **/temp) + sendEvent("music/rock/artist/temp") + + // Wait to ensure event is processed + time.Sleep(50 * time.Millisecond) + + // No notification should be sent + Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for nested ignored folder") + }) + + It("should send notification for non-ignored nested folder", func() { + startEventProcessing() + + // Simulate change in music/rock/artist (doesn't match any pattern) + sendEvent("music/rock/artist") + + // Should receive notification + Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{ + Library: lib, + FolderPath: "music/rock/artist", + }))) + }) + }) + + Context("with file events in ignored folders", func() { + BeforeEach(func() { + mockFS = &mockMusicFS{ + FS: fstest.MapFS{ + "rock": &fstest.MapFile{Mode: fs.ModeDir}, + "rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")}, + }, + } + }) + + It("should NOT send notification for file changes in ignored folders", func() { + startEventProcessing() + + // Simulate file change in rock/_TEMP/file.mp3 + sendEvent("rock/_TEMP/file.mp3") + + // Wait to ensure event is processed + time.Sleep(50 * time.Millisecond) + + // No notification should be sent + Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder") + }) + }) + }) +}) + +var _ = Describe("resolveFolderPath", func() { + var mockFS fs.FS + + BeforeEach(func() { + // Create a mock filesystem with some directories and files + mockFS = fstest.MapFS{ + "artist1": &fstest.MapFile{Mode: fs.ModeDir}, + "artist1/album1": &fstest.MapFile{Mode: fs.ModeDir}, + "artist1/album1/track1.mp3": &fstest.MapFile{Data: []byte("audio")}, + "artist1/album1/track2.mp3": &fstest.MapFile{Data: []byte("audio")}, + "artist1/album2": &fstest.MapFile{Mode: fs.ModeDir}, + "artist1/album2/song.flac": &fstest.MapFile{Data: []byte("audio")}, + "artist2": &fstest.MapFile{Mode: fs.ModeDir}, + "artist2/cover.jpg": &fstest.MapFile{Data: []byte("image")}, + } + }) + + It("returns directory path when given a directory", func() { + result := resolveFolderPath(mockFS, "artist1/album1") + Expect(result).To(Equal("artist1/album1")) + }) + + It("walks up to parent directory when given a file path", func() { + result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3") + Expect(result).To(Equal("artist1/album1")) + }) + + It("walks up multiple levels if needed", func() { + result := resolveFolderPath(mockFS, "artist1/album1/nonexistent/file.mp3") + Expect(result).To(Equal("artist1/album1")) + }) + + It("returns empty string for non-existent paths at root", func() { + result := resolveFolderPath(mockFS, "nonexistent/path/file.mp3") + Expect(result).To(Equal("")) + }) + + It("returns empty string for dot path", func() { + result := resolveFolderPath(mockFS, ".") + Expect(result).To(Equal("")) + }) + + It("returns empty string for empty path", func() { + result := resolveFolderPath(mockFS, "") + Expect(result).To(Equal("")) + }) + + It("handles nested file paths correctly", func() { + result := resolveFolderPath(mockFS, "artist1/album2/song.flac") + Expect(result).To(Equal("artist1/album2")) + }) + + It("resolves to top-level directory", func() { + result := resolveFolderPath(mockFS, "artist2/cover.jpg") + Expect(result).To(Equal("artist2")) + }) +}) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index d08d3eb5b..f0e73c3d2 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -18,7 +18,6 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -39,7 +38,7 @@ type Router struct { players core.Players provider external.Provider playlists core.Playlists - scanner scanner.Scanner + scanner model.Scanner broker events.Broker scrobbler scrobbler.PlayTracker share core.Share @@ -48,7 +47,7 @@ type Router struct { } func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver, - players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker, + players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, ) *Router { diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index b6ccb9ae6..c9dd64968 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -1,10 +1,13 @@ package subsonic import ( + "fmt" "net/http" + "slices" "time" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" @@ -44,15 +47,56 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) fullScan := p.BoolOr("fullScan", false) + // Parse optional target parameters for selective scanning + var targets []model.ScanTarget + if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 { + targets, err = model.ParseTargets(targetParams) + if err != nil { + return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err)) + } + + // Validate all libraries in targets exist and user has access to them + userLibraries, err := api.ds.User(ctx).GetUserLibraries(loggedUser.ID) + if err != nil { + return nil, newError(responses.ErrorGeneric, "Internal error") + } + + // Check each target library + for _, target := range targets { + if !slices.ContainsFunc(userLibraries, func(lib model.Library) bool { return lib.ID == target.LibraryID }) { + return nil, newError(responses.ErrorDataNotFound, fmt.Sprintf("Library with ID %d not found", target.LibraryID)) + } + } + + // Special case: if single library with empty path and it's the only library in DB, call ScanAll + if len(targets) == 1 && targets[0].FolderPath == "" { + allLibs, err := api.ds.Library(ctx).GetAll() + if err != nil { + return nil, newError(responses.ErrorGeneric, "Internal error") + } + if len(allLibs) == 1 { + targets = nil // This will trigger ScanAll below + } + } + } + go func() { start := time.Now() - log.Info(ctx, "Triggering manual scan", "fullScan", fullScan, "user", loggedUser.UserName) - _, err := api.scanner.ScanAll(ctx, fullScan) + var err error + + if len(targets) > 0 { + log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "targets", len(targets), "user", loggedUser.UserName) + _, err = api.scanner.ScanFolders(ctx, fullScan, targets) + } else { + log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "user", loggedUser.UserName) + _, err = api.scanner.ScanAll(ctx, fullScan) + } + if err != nil { log.Error(ctx, "Error scanning", err) return } - log.Info(ctx, "Manual scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start)) + log.Info(ctx, "On-demand scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start)) }() return api.GetScanStatus(r) diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go new file mode 100644 index 000000000..d8eba296b --- /dev/null +++ b/server/subsonic/library_scanning_test.go @@ -0,0 +1,396 @@ +package subsonic + +import ( + "context" + "errors" + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LibraryScanning", func() { + var api *Router + var ms *tests.MockScanner + + BeforeEach(func() { + ms = tests.NewMockScanner() + api = &Router{scanner: ms} + }) + + Describe("StartScan", func() { + It("requires admin authentication", func() { + // Create non-admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "user-id", + IsAdmin: false, + }) + + // Create request + r := httptest.NewRequest("GET", "/rest/startScan", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should return authorization error + Expect(err).To(HaveOccurred()) + Expect(response).To(BeNil()) + var subErr subError + ok := errors.As(err, &subErr) + Expect(ok).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail)) + }) + + It("triggers a full scan with no parameters", func() { + // Create admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + // Create request with no parameters + r := httptest.NewRequest("GET", "/rest/startScan", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should succeed + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + + // Verify ScanAll was called (eventually, since it's in a goroutine) + Eventually(func() int { + return ms.GetScanAllCallCount() + }).Should(BeNumerically(">", 0)) + calls := ms.GetScanAllCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].FullScan).To(BeFalse()) + }) + + It("triggers a full scan with fullScan=true", func() { + // Create admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + // Create request with fullScan parameter + r := httptest.NewRequest("GET", "/rest/startScan?fullScan=true", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should succeed + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + + // Verify ScanAll was called with fullScan=true + Eventually(func() int { + return ms.GetScanAllCallCount() + }).Should(BeNumerically(">", 0)) + calls := ms.GetScanAllCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].FullScan).To(BeTrue()) + }) + + It("triggers a selective scan with single target parameter", func() { + // Setup mocks + mockUserRepo := tests.CreateMockUserRepo() + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) + mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} + api.ds = mockDS + + // Create admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + // Create request with single target parameter + r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Rock", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should succeed + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + + // Verify ScanFolders was called with correct targets + Eventually(func() int { + return ms.GetScanFoldersCallCount() + }).Should(BeNumerically(">", 0)) + calls := ms.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + targets := calls[0].Targets + Expect(targets).To(HaveLen(1)) + Expect(targets[0].LibraryID).To(Equal(1)) + Expect(targets[0].FolderPath).To(Equal("Music/Rock")) + }) + + It("triggers a selective scan with multiple target parameters", func() { + // Setup mocks + mockUserRepo := tests.CreateMockUserRepo() + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) + mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} + api.ds = mockDS + + // Create admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + // Create request with multiple target parameters + r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Reggae&target=2:Classical/Bach", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should succeed + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + + // Verify ScanFolders was called with correct targets + Eventually(func() int { + return ms.GetScanFoldersCallCount() + }).Should(BeNumerically(">", 0)) + calls := ms.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + targets := calls[0].Targets + Expect(targets).To(HaveLen(2)) + Expect(targets[0].LibraryID).To(Equal(1)) + Expect(targets[0].FolderPath).To(Equal("Music/Reggae")) + Expect(targets[1].LibraryID).To(Equal(2)) + Expect(targets[1].FolderPath).To(Equal("Classical/Bach")) + }) + + It("triggers a selective full scan with target and fullScan parameters", func() { + // Setup mocks + mockUserRepo := tests.CreateMockUserRepo() + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1}) + mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} + api.ds = mockDS + + // Create admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + // Create request with target and fullScan parameters + r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Jazz&fullScan=true", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should succeed + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + + // Verify ScanFolders was called with fullScan=true + Eventually(func() int { + return ms.GetScanFoldersCallCount() + }).Should(BeNumerically(">", 0)) + calls := ms.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].FullScan).To(BeTrue()) + targets := calls[0].Targets + Expect(targets).To(HaveLen(1)) + }) + + It("returns error for invalid target format", func() { + // Create admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + // Create request with invalid target format (missing colon) + r := httptest.NewRequest("GET", "/rest/startScan?target=1MusicRock", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should return error + Expect(err).To(HaveOccurred()) + Expect(response).To(BeNil()) + var subErr subError + ok := errors.As(err, &subErr) + Expect(ok).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorGeneric)) + }) + + It("returns error for invalid library ID in target", func() { + // Create admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + // Create request with invalid library ID + r := httptest.NewRequest("GET", "/rest/startScan?target=0:Music/Rock", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should return error + Expect(err).To(HaveOccurred()) + Expect(response).To(BeNil()) + var subErr subError + ok := errors.As(err, &subErr) + Expect(ok).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorGeneric)) + }) + + It("returns error when library does not exist", func() { + // Setup mocks - user has access to library 1 and 2 only + mockUserRepo := tests.CreateMockUserRepo() + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) + mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} + api.ds = mockDS + + // Create admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + // Create request with library ID that doesn't exist + r := httptest.NewRequest("GET", "/rest/startScan?target=999:Music/Rock", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should return ErrorDataNotFound + Expect(err).To(HaveOccurred()) + Expect(response).To(BeNil()) + var subErr subError + ok := errors.As(err, &subErr) + Expect(ok).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorDataNotFound)) + }) + + It("calls ScanAll when single library with empty path and only one library exists", func() { + // Setup mocks - single library in DB + mockUserRepo := tests.CreateMockUserRepo() + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1}) + mockLibraryRepo := &tests.MockLibraryRepo{} + mockLibraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Music Library", Path: "/music"}, + }) + mockDS := &tests.MockDataStore{ + MockedUser: mockUserRepo, + MockedLibrary: mockLibraryRepo, + } + api.ds = mockDS + + // Create admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + // Create request with single library and empty path + r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should succeed + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + + // Verify ScanAll was called instead of ScanFolders + Eventually(func() int { + return ms.GetScanAllCallCount() + }).Should(BeNumerically(">", 0)) + Expect(ms.GetScanFoldersCallCount()).To(Equal(0)) + }) + + It("calls ScanFolders when single library with empty path but multiple libraries exist", func() { + // Setup mocks - multiple libraries in DB + mockUserRepo := tests.CreateMockUserRepo() + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) + mockLibraryRepo := &tests.MockLibraryRepo{} + mockLibraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Music Library", Path: "/music"}, + {ID: 2, Name: "Audiobooks", Path: "/audiobooks"}, + }) + mockDS := &tests.MockDataStore{ + MockedUser: mockUserRepo, + MockedLibrary: mockLibraryRepo, + } + api.ds = mockDS + + // Create admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + // Create request with single library and empty path + r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should succeed + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + + // Verify ScanFolders was called (not ScanAll) + Eventually(func() int { + return ms.GetScanFoldersCallCount() + }).Should(BeNumerically(">", 0)) + calls := ms.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + targets := calls[0].Targets + Expect(targets).To(HaveLen(1)) + Expect(targets[0].LibraryID).To(Equal(1)) + Expect(targets[0].FolderPath).To(Equal("")) + }) + }) + + Describe("GetScanStatus", func() { + It("returns scan status", func() { + // Setup mock scanner status + ms.SetStatusResponse(&model.ScannerStatus{ + Scanning: false, + Count: 100, + FolderCount: 10, + }) + + // Create request + ctx := context.Background() + r := httptest.NewRequest("GET", "/rest/getScanStatus", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.GetScanStatus(r) + + // Should succeed + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + Expect(response.ScanStatus).ToNot(BeNil()) + Expect(response.ScanStatus.Scanning).To(BeFalse()) + Expect(response.ScanStatus.Count).To(Equal(int64(100))) + Expect(response.ScanStatus.FolderCount).To(Equal(int64(10))) + }) + }) +}) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 56f68a74b..ba586ab53 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -28,6 +28,10 @@ type MockDataStore struct { MockedRadio model.RadioRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex + + // GC tracking + GCCalled bool + GCError error } func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { @@ -258,6 +262,10 @@ func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepo } } -func (db *MockDataStore) GC(context.Context) error { +func (db *MockDataStore) GC(context.Context, ...int) error { + db.GCCalled = true + if db.GCError != nil { + return db.GCError + } return nil } diff --git a/tests/mock_scanner.go b/tests/mock_scanner.go new file mode 100644 index 000000000..52396723f --- /dev/null +++ b/tests/mock_scanner.go @@ -0,0 +1,120 @@ +package tests + +import ( + "context" + "sync" + + "github.com/navidrome/navidrome/model" +) + +// MockScanner implements scanner.Scanner for testing with proper synchronization +type MockScanner struct { + mu sync.Mutex + scanAllCalls []ScanAllCall + scanFoldersCalls []ScanFoldersCall + scanningStatus bool + statusResponse *model.ScannerStatus +} + +type ScanAllCall struct { + FullScan bool +} + +type ScanFoldersCall struct { + FullScan bool + Targets []model.ScanTarget +} + +func NewMockScanner() *MockScanner { + return &MockScanner{ + scanAllCalls: make([]ScanAllCall, 0), + scanFoldersCalls: make([]ScanFoldersCall, 0), + } +} + +func (m *MockScanner) ScanAll(_ context.Context, fullScan bool) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.scanAllCalls = append(m.scanAllCalls, ScanAllCall{FullScan: fullScan}) + + return nil, nil +} + +func (m *MockScanner) ScanFolders(_ context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Make a copy of targets to avoid race conditions + targetsCopy := make([]model.ScanTarget, len(targets)) + copy(targetsCopy, targets) + + m.scanFoldersCalls = append(m.scanFoldersCalls, ScanFoldersCall{ + FullScan: fullScan, + Targets: targetsCopy, + }) + + return nil, nil +} + +func (m *MockScanner) Status(_ context.Context) (*model.ScannerStatus, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.statusResponse != nil { + return m.statusResponse, nil + } + + return &model.ScannerStatus{ + Scanning: m.scanningStatus, + }, nil +} + +func (m *MockScanner) GetScanAllCallCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.scanAllCalls) +} + +func (m *MockScanner) GetScanAllCalls() []ScanAllCall { + m.mu.Lock() + defer m.mu.Unlock() + // Return a copy to avoid race conditions + calls := make([]ScanAllCall, len(m.scanAllCalls)) + copy(calls, m.scanAllCalls) + return calls +} + +func (m *MockScanner) GetScanFoldersCallCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.scanFoldersCalls) +} + +func (m *MockScanner) GetScanFoldersCalls() []ScanFoldersCall { + m.mu.Lock() + defer m.mu.Unlock() + // Return a copy to avoid race conditions + calls := make([]ScanFoldersCall, len(m.scanFoldersCalls)) + copy(calls, m.scanFoldersCalls) + return calls +} + +func (m *MockScanner) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.scanAllCalls = make([]ScanAllCall, 0) + m.scanFoldersCalls = make([]ScanFoldersCall, 0) +} + +func (m *MockScanner) SetScanning(scanning bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.scanningStatus = scanning +} + +func (m *MockScanner) SetStatusResponse(status *model.ScannerStatus) { + m.mu.Lock() + defer m.mu.Unlock() + m.statusResponse = status +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 4a9039a67..9ef65d668 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -302,6 +302,8 @@ }, "actions": { "scan": "Scan Library", + "quickScan": "Quick Scan", + "fullScan": "Full Scan", "manageUsers": "Manage User Access", "viewDetails": "View Details" }, @@ -310,6 +312,9 @@ "updated": "Library updated successfully", "deleted": "Library deleted successfully", "scanStarted": "Library scan started", + "quickScanStarted": "Quick scan started", + "fullScanStarted": "Full scan started", + "scanError": "Error starting scan. Check logs", "scanCompleted": "Library scan completed" }, "validation": { @@ -600,11 +605,12 @@ "activity": { "title": "Activity", "totalScanned": "Total Folders Scanned", - "quickScan": "Quick Scan", - "fullScan": "Full Scan", + "quickScan": "Quick", + "fullScan": "Full", + "selectiveScan": "Selective", "serverUptime": "Server Uptime", "serverDown": "OFFLINE", - "scanType": "Type", + "scanType": "Last Scan", "status": "Scan Error", "elapsedTime": "Elapsed Time" }, diff --git a/ui/src/layout/ActivityPanel.jsx b/ui/src/layout/ActivityPanel.jsx index 18af8dc93..6d5d32d31 100644 --- a/ui/src/layout/ActivityPanel.jsx +++ b/ui/src/layout/ActivityPanel.jsx @@ -113,6 +113,9 @@ const ActivityPanel = () => { return translate('activity.fullScan') case 'quick': return translate('activity.quickScan') + case 'full-selective': + case 'quick-selective': + return translate('activity.selectiveScan') default: return '' } diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx index 932732b10..f3032cbd1 100644 --- a/ui/src/library/LibraryList.jsx +++ b/ui/src/library/LibraryList.jsx @@ -10,6 +10,8 @@ import { } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import { List, DateField, useResourceRefresh, SizeField } from '../common' +import LibraryListBulkActions from './LibraryListBulkActions' +import LibraryListActions from './LibraryListActions' const LibraryFilter = (props) => ( @@ -26,8 +28,9 @@ const LibraryList = (props) => { {...props} sort={{ field: 'name', order: 'ASC' }} exporter={false} - bulkActionButtons={false} + bulkActionButtons={!isXsmall && } filters={} + actions={} > {isXsmall ? ( { + return ( + + {filters && + cloneElement(filters, { + resource, + showFilter, + displayedFilters, + filterValues, + context: 'button', + })} + + + + ) +} + +export default LibraryListActions diff --git a/ui/src/library/LibraryListBulkActions.jsx b/ui/src/library/LibraryListBulkActions.jsx new file mode 100644 index 000000000..8862a4f51 --- /dev/null +++ b/ui/src/library/LibraryListBulkActions.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import LibraryScanButton from './LibraryScanButton' + +const LibraryListBulkActions = (props) => ( + <> + + + +) + +export default LibraryListBulkActions diff --git a/ui/src/library/LibraryScanButton.jsx b/ui/src/library/LibraryScanButton.jsx new file mode 100644 index 000000000..50d90e615 --- /dev/null +++ b/ui/src/library/LibraryScanButton.jsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { + Button, + useNotify, + useRefresh, + useTranslate, + useUnselectAll, +} from 'react-admin' +import { useSelector } from 'react-redux' +import SyncIcon from '@material-ui/icons/Sync' +import CachedIcon from '@material-ui/icons/Cached' +import subsonic from '../subsonic' + +const LibraryScanButton = ({ fullScan, selectedIds, className }) => { + const [loading, setLoading] = useState(false) + const notify = useNotify() + const refresh = useRefresh() + const translate = useTranslate() + const unselectAll = useUnselectAll() + const scanStatus = useSelector((state) => state.activity.scanStatus) + + const handleClick = async () => { + setLoading(true) + try { + // Build scan options + const options = { fullScan } + + // If specific libraries are selected, scan only those + // Format: "libraryID:" to scan entire library (no folder path specified) + if (selectedIds && selectedIds.length > 0) { + options.target = selectedIds.map((id) => `${id}:`) + } + + await subsonic.startScan(options) + const notificationKey = fullScan + ? 'resources.library.notifications.fullScanStarted' + : 'resources.library.notifications.quickScanStarted' + notify(notificationKey, 'info') + refresh() + + // Unselect all items after successful scan + unselectAll('library') + } catch (error) { + notify('resources.library.notifications.scanError', 'warning') + } finally { + setLoading(false) + } + } + + const isDisabled = loading || scanStatus.scanning + + const label = fullScan + ? translate('resources.library.actions.fullScan') + : translate('resources.library.actions.quickScan') + + const icon = fullScan ? : + + return ( + + ) +} + +LibraryScanButton.propTypes = { + fullScan: PropTypes.bool.isRequired, + selectedIds: PropTypes.array, + className: PropTypes.string, +} + +export default LibraryScanButton diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index ad7a391e0..cfcc01043 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -23,7 +23,13 @@ const url = (command, id, options) => { delete options.ts } Object.keys(options).forEach((k) => { - params.append(k, options[k]) + const value = options[k] + // Handle array parameters by appending each value separately + if (Array.isArray(value)) { + value.forEach((v) => params.append(k, v)) + } else { + params.append(k, value) + } }) } return `/rest/${command}?${params.toString()}` diff --git a/utils/slice/slice.go b/utils/slice/slice.go index 1d7c64f50..b1f50afcc 100644 --- a/utils/slice/slice.go +++ b/utils/slice/slice.go @@ -171,3 +171,14 @@ func SeqFunc[I, O any](s []I, f func(I) O) iter.Seq[O] { } } } + +// Filter returns a new slice containing only the elements of s for which filterFunc returns true +func Filter[T any](s []T, filterFunc func(T) bool) []T { + var result []T + for _, item := range s { + if filterFunc(item) { + result = append(result, item) + } + } + return result +} diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go index c6d4be1e0..65e5f0934 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -172,4 +172,42 @@ var _ = Describe("Slice Utils", func() { Expect(result).To(ConsistOf("2", "4", "6", "8")) }) }) + + Describe("Filter", func() { + It("returns empty slice for an empty input", func() { + filterFunc := func(v int) bool { return v > 0 } + result := slice.Filter([]int{}, filterFunc) + Expect(result).To(BeEmpty()) + }) + + It("returns all elements when filter matches all", func() { + filterFunc := func(v int) bool { return v > 0 } + result := slice.Filter([]int{1, 2, 3, 4}, filterFunc) + Expect(result).To(HaveExactElements(1, 2, 3, 4)) + }) + + It("returns empty slice when filter matches none", func() { + filterFunc := func(v int) bool { return v > 10 } + result := slice.Filter([]int{1, 2, 3, 4}, filterFunc) + Expect(result).To(BeEmpty()) + }) + + It("returns only matching elements", func() { + filterFunc := func(v int) bool { return v%2 == 0 } + result := slice.Filter([]int{1, 2, 3, 4, 5, 6}, filterFunc) + Expect(result).To(HaveExactElements(2, 4, 6)) + }) + + It("works with string slices", func() { + filterFunc := func(s string) bool { return len(s) > 3 } + result := slice.Filter([]string{"a", "abc", "abcd", "ab", "abcde"}, filterFunc) + Expect(result).To(HaveExactElements("abcd", "abcde")) + }) + + It("preserves order of elements", func() { + filterFunc := func(v int) bool { return v%2 == 1 } + result := slice.Filter([]int{9, 8, 7, 6, 5, 4, 3, 2, 1}, filterFunc) + Expect(result).To(HaveExactElements(9, 7, 5, 3, 1)) + }) + }) }) From 0161a0958c3e2ab7e296bb35e43df97e51babe6f Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 15 Nov 2025 17:31:37 -0500 Subject: [PATCH 10/42] fix(ui): add CreateButton back to LibraryListActions Signed-off-by: Deluan --- ui/src/library/LibraryListActions.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/library/LibraryListActions.jsx b/ui/src/library/LibraryListActions.jsx index f6f1ca90d..f4d0913df 100644 --- a/ui/src/library/LibraryListActions.jsx +++ b/ui/src/library/LibraryListActions.jsx @@ -1,5 +1,5 @@ import React, { cloneElement } from 'react' -import { sanitizeListRestProps, TopToolbar } from 'react-admin' +import { sanitizeListRestProps, TopToolbar, CreateButton } from 'react-admin' import LibraryScanButton from './LibraryScanButton' const LibraryListActions = ({ @@ -23,6 +23,7 @@ const LibraryListActions = ({ })} + ) } From 395a36e10f2d3f4af8cccbfa81b0da1e556a0d36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 15 Nov 2025 17:42:28 -0500 Subject: [PATCH 11/42] fix(ui): fix library selection state for single-library users (#4686) * fix: validate library selection state for single-library users Fixes issues where users with a single library see no content when selectedLibraries in localStorage contains library IDs they no longer have access to (e.g., after removing libraries or switching accounts). Changes: - libraryReducer: Validate selectedLibraries when SET_USER_LIBRARIES is dispatched, filtering out invalid IDs and resetting to empty for single-library users (empty means 'all accessible libraries') - wrapperDataProvider: Add defensive validation in getSelectedLibraries to check against current user libraries before applying filters - Add comprehensive test coverage for reducer validation logic Fixes #4553, #4508, #4569 * style: format code with prettier --- ui/src/dataProvider/wrapperDataProvider.js | 16 +- ui/src/reducers/libraryReducer.js | 37 +++- ui/src/reducers/libraryReducer.test.js | 186 +++++++++++++++++++++ 3 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 ui/src/reducers/libraryReducer.test.js diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index 8b4a0cb62..268d3668d 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -12,7 +12,21 @@ const isAdmin = () => { const getSelectedLibraries = () => { try { const state = JSON.parse(localStorage.getItem('state')) - return state?.library?.selectedLibraries || [] + const selectedLibraries = state?.library?.selectedLibraries || [] + const userLibraries = state?.library?.userLibraries || [] + + // Validate selected libraries against current user libraries + const userLibraryIds = userLibraries.map((lib) => lib.id) + const validatedSelection = selectedLibraries.filter((id) => + userLibraryIds.includes(id), + ) + + // If user has only one library, return empty array (no filter needed) + if (userLibraryIds.length === 1) { + return [] + } + + return validatedSelection } catch (err) { return [] } diff --git a/ui/src/reducers/libraryReducer.js b/ui/src/reducers/libraryReducer.js index 7cda10bcf..ef613260f 100644 --- a/ui/src/reducers/libraryReducer.js +++ b/ui/src/reducers/libraryReducer.js @@ -8,18 +8,39 @@ const initialState = { export const libraryReducer = (previousState = initialState, payload) => { const { type, data } = payload switch (type) { - case SET_USER_LIBRARIES: + case SET_USER_LIBRARIES: { + const newUserLibraryIds = data.map((lib) => lib.id) + + // Validate and filter selected libraries to only include IDs that exist in new user libraries + const validatedSelection = previousState.selectedLibraries.filter((id) => + newUserLibraryIds.includes(id), + ) + + // Determine the final selection: + // 1. If first time setting libraries (no previous user libraries), select all + // 2. If user now has only one library, reset to empty (no filter needed) + // 3. Otherwise, use validated selection (may be empty if all previous selections were invalid) + let finalSelection + if ( + previousState.selectedLibraries.length === 0 && + previousState.userLibraries.length === 0 + ) { + // First time: select all libraries + finalSelection = newUserLibraryIds + } else if (newUserLibraryIds.length === 1) { + // Single library: reset selection (empty means "all accessible") + finalSelection = [] + } else { + // Multiple libraries: use validated selection + finalSelection = validatedSelection + } + return { ...previousState, userLibraries: data, - // If this is the first time setting user libraries and no selection exists, - // default to all libraries - selectedLibraries: - previousState.selectedLibraries.length === 0 && - previousState.userLibraries.length === 0 - ? data.map((lib) => lib.id) - : previousState.selectedLibraries, + selectedLibraries: finalSelection, } + } case SET_SELECTED_LIBRARIES: return { ...previousState, diff --git a/ui/src/reducers/libraryReducer.test.js b/ui/src/reducers/libraryReducer.test.js new file mode 100644 index 000000000..b962c1036 --- /dev/null +++ b/ui/src/reducers/libraryReducer.test.js @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest' +import { libraryReducer } from './libraryReducer' +import { SET_SELECTED_LIBRARIES, SET_USER_LIBRARIES } from '../actions' + +describe('libraryReducer', () => { + const mockLibraries = [ + { id: '1', name: 'Music Library' }, + { id: '2', name: 'Podcasts' }, + { id: '3', name: 'Audiobooks' }, + ] + + const initialState = { + userLibraries: [], + selectedLibraries: [], + } + + describe('SET_USER_LIBRARIES', () => { + it('should set user libraries and select all on first load', () => { + const action = { + type: SET_USER_LIBRARIES, + data: mockLibraries, + } + + const result = libraryReducer(initialState, action) + + expect(result.userLibraries).toEqual(mockLibraries) + expect(result.selectedLibraries).toEqual(['1', '2', '3']) + }) + + it('should reset selection to empty when user has only one library', () => { + const previousState = { + userLibraries: mockLibraries, + selectedLibraries: ['1', '2'], + } + + const action = { + type: SET_USER_LIBRARIES, + data: [mockLibraries[0]], // Only one library now + } + + const result = libraryReducer(previousState, action) + + expect(result.userLibraries).toEqual([mockLibraries[0]]) + expect(result.selectedLibraries).toEqual([]) // Reset for single library + }) + + it('should filter out invalid library IDs from selection', () => { + const previousState = { + userLibraries: mockLibraries, + selectedLibraries: ['1', '2', '3'], + } + + const action = { + type: SET_USER_LIBRARIES, + data: [mockLibraries[0], mockLibraries[1]], // Only libraries 1 and 2 remain + } + + const result = libraryReducer(previousState, action) + + expect(result.userLibraries).toEqual([mockLibraries[0], mockLibraries[1]]) + expect(result.selectedLibraries).toEqual(['1', '2']) // Library 3 removed + }) + + it('should keep valid selection when libraries change', () => { + const previousState = { + userLibraries: mockLibraries, + selectedLibraries: ['1'], + } + + const action = { + type: SET_USER_LIBRARIES, + data: mockLibraries, // Same libraries + } + + const result = libraryReducer(previousState, action) + + expect(result.userLibraries).toEqual(mockLibraries) + expect(result.selectedLibraries).toEqual(['1']) // Selection preserved + }) + + it('should handle selection becoming empty after filtering invalid IDs', () => { + const previousState = { + userLibraries: mockLibraries, + selectedLibraries: ['1', '2'], + } + + const newLibraries = [{ id: '4', name: 'New Library' }] + const action = { + type: SET_USER_LIBRARIES, + data: newLibraries, + } + + const result = libraryReducer(previousState, action) + + expect(result.userLibraries).toEqual(newLibraries) + expect(result.selectedLibraries).toEqual([]) // All selected IDs were invalid + }) + + it('should handle transition from multiple to single library with invalid selection', () => { + const previousState = { + userLibraries: mockLibraries, + selectedLibraries: ['2', '3'], // User had libraries 2 and 3 selected + } + + const action = { + type: SET_USER_LIBRARIES, + data: [mockLibraries[0]], // Now only has access to library 1 + } + + const result = libraryReducer(previousState, action) + + expect(result.userLibraries).toEqual([mockLibraries[0]]) + expect(result.selectedLibraries).toEqual([]) // Reset for single library + }) + + it('should handle empty library list', () => { + const previousState = { + userLibraries: mockLibraries, + selectedLibraries: ['1', '2'], + } + + const action = { + type: SET_USER_LIBRARIES, + data: [], + } + + const result = libraryReducer(previousState, action) + + expect(result.userLibraries).toEqual([]) + expect(result.selectedLibraries).toEqual([]) // All selections filtered out + }) + }) + + describe('SET_SELECTED_LIBRARIES', () => { + it('should update selected libraries', () => { + const previousState = { + userLibraries: mockLibraries, + selectedLibraries: ['1'], + } + + const action = { + type: SET_SELECTED_LIBRARIES, + data: ['2', '3'], + } + + const result = libraryReducer(previousState, action) + + expect(result.selectedLibraries).toEqual(['2', '3']) + expect(result.userLibraries).toEqual(mockLibraries) // Unchanged + }) + + it('should allow setting empty selection', () => { + const previousState = { + userLibraries: mockLibraries, + selectedLibraries: ['1', '2'], + } + + const action = { + type: SET_SELECTED_LIBRARIES, + data: [], + } + + const result = libraryReducer(previousState, action) + + expect(result.selectedLibraries).toEqual([]) + }) + }) + + describe('unknown action', () => { + it('should return previous state for unknown action', () => { + const previousState = { + userLibraries: mockLibraries, + selectedLibraries: ['1'], + } + + const action = { + type: 'UNKNOWN_ACTION', + data: null, + } + + const result = libraryReducer(previousState, action) + + expect(result).toBe(previousState) // Same reference + }) + }) +}) From 0f1ede25817b837af6d4a39078de74560a102880 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 16 Nov 2025 17:54:28 +0000 Subject: [PATCH 12/42] fix(scanner): specify exact table to use for missing mediafile filter (#4689) In `getAffectedAlbumIDs`, when one or more IDs is added, it adds a filter `"id": ids`. This filter is ambiguous though, because the `getAll` query joins with library table, which _also_ has an `id` field. Clarify this by adding the table name to the filter. Note that this was not caught in testing, as it only uses mock db. --- core/maintenance.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/maintenance.go b/core/maintenance.go index c2f65d74f..750fd3a9e 100644 --- a/core/maintenance.go +++ b/core/maintenance.go @@ -166,7 +166,7 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri if len(ids) > 0 { filters = squirrel.And{ squirrel.Eq{"missing": true}, - squirrel.Eq{"id": ids}, + squirrel.Eq{"media_file.id": ids}, } } From 489d5c7760e770b43e4a323aa709be787a991826 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 16 Nov 2025 13:41:22 -0500 Subject: [PATCH 13/42] test: update make test-race target to use PKG variable for improved flexibility Signed-off-by: Deluan --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index df8155f56..2a60b7165 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ testall: test-race test-i18n test-js ##@Development Run Go and JS tests .PHONY: testall test-race: ##@Development Run Go tests with race detector - go test -tags netgo -race -shuffle=on ./... + go test -tags netgo -race -shuffle=on $(PKG) .PHONY: test-race test-js: ##@Development Run JS tests From 32e1313fc6ddf7100af094d14df13d47735a44bf Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:46:32 +0000 Subject: [PATCH 14/42] ci: bump plugin compilation timeout for regressions (#4690) --- plugins/manager_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 207908ebc..8b361f8b3 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" @@ -22,8 +23,11 @@ var _ = Describe("Plugin Manager", func() { // but, as this is an integration test, we can't use configtest.SetupConfig() as it causes // data races. originalPluginsFolder := conf.Server.Plugins.Folder + originalTimeout := conf.Server.DevPluginCompilationTimeout + conf.Server.DevPluginCompilationTimeout = 2 * time.Minute DeferCleanup(func() { conf.Server.Plugins.Folder = originalPluginsFolder + conf.Server.DevPluginCompilationTimeout = originalTimeout }) conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = testDataDir From 6fb228bc1044e4f97ccac31e9c753a05fbef84c8 Mon Sep 17 00:00:00 2001 From: Dongeun <28642090+dongeunm@users.noreply.github.com> Date: Thu, 20 Nov 2025 02:42:33 +0800 Subject: [PATCH 15/42] fix(ui): fix translation display for library list terms (#4712) --- ui/src/library/LibraryList.jsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx index f3032cbd1..aa1294882 100644 --- a/ui/src/library/LibraryList.jsx +++ b/ui/src/library/LibraryList.jsx @@ -42,15 +42,11 @@ const LibraryList = (props) => { - - - + + + - + )} From 3d1946e31c3df26cb123a13b7064b941302123cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 19 Nov 2025 20:17:01 -0500 Subject: [PATCH 16/42] fix(plugins): avoid Chi RouteContext pollution by using http.NewRequest (#4713) Signed-off-by: Deluan --- plugins/host_subsonicapi.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index d3008798a..937dd044f 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -93,8 +93,12 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, req *subsonicapi.Call RawQuery: query.Encode(), } - // Create HTTP request with internal authentication - httpReq, err := http.NewRequestWithContext(ctx, "GET", finalURL.String(), nil) + // Create HTTP request with a fresh context to avoid Chi RouteContext pollution. + // Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal + // SubsonicAPI call doesn't inherit routing information from the parent handler, + // which would cause Chi to invoke the wrong handler. Authentication context is + // explicitly added in the next step via request.WithInternalAuth. + httpReq, err := http.NewRequest("GET", finalURL.String(), nil) if err != nil { return &subsonicapi.CallResponse{ Error: fmt.Sprintf("failed to create HTTP request: %v", err), From c873466e5b33a5782e62ee25bafe31c92e636f21 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 19 Nov 2025 20:24:13 -0500 Subject: [PATCH 17/42] fix(scanner): reset watcher trigger timer for debounce on notification receipt Signed-off-by: Deluan --- scanner/watcher.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scanner/watcher.go b/scanner/watcher.go index ad9a06421..3efebaacc 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -122,6 +122,9 @@ func (w *watcher) Run(ctx context.Context) error { w.mu.Unlock() return nil case notification := <-w.watcherNotify: + // Reset the trigger timer for debounce + trigger.Reset(w.triggerWait) + lib := notification.Library folderPath := notification.FolderPath @@ -131,7 +134,6 @@ func (w *watcher) Run(ctx context.Context) error { continue } targets[target] = struct{}{} - trigger.Reset(w.triggerWait) log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "folderPath", folderPath) From 353aff2c88e287e9cc40d4f5266b1b8dd757960e Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 19 Nov 2025 20:49:29 -0500 Subject: [PATCH 18/42] fix(lastfm): ignore artist placeholder image. Fix #4702 Signed-off-by: Deluan --- core/agents/lastfm/agent.go | 27 +++++--- core/agents/lastfm/agent_test.go | 69 +++++++++++++++++++ tests/fixtures/lastfm.artist.page.html | 7 ++ .../fixtures/lastfm.artist.page.ignored.html | 7 ++ .../fixtures/lastfm.artist.page.no_meta.html | 6 ++ 5 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/lastfm.artist.page.html create mode 100644 tests/fixtures/lastfm.artist.page.ignored.html create mode 100644 tests/fixtures/lastfm.artist.page.no_meta.html diff --git a/core/agents/lastfm/agent.go b/core/agents/lastfm/agent.go index d01b496ec..fafa6afec 100644 --- a/core/agents/lastfm/agent.go +++ b/core/agents/lastfm/agent.go @@ -38,6 +38,7 @@ type lastfmAgent struct { secret string lang string client *client + httpClient httpDoer getInfoMutex sync.Mutex } @@ -56,6 +57,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent { Timeout: consts.DefaultHttpClientTimeOut, } chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) + l.httpClient = chc l.client = newClient(l.apiKey, l.secret, l.lang, chc) return l } @@ -190,13 +192,13 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi return res, nil } -var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`) +var ( + artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`) + artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name +) func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) { log.Debug(ctx, "Getting artist images from Last.fm", "name", name) - hc := http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } a, err := l.callArtistGetInfo(ctx, name) if err != nil { return nil, fmt.Errorf("get artist info: %w", err) @@ -205,7 +207,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) if err != nil { return nil, fmt.Errorf("create artist image request: %w", err) } - resp, err := hc.Do(req) + resp, err := l.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("get artist url: %w", err) } @@ -222,11 +224,16 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) return res, nil } for _, attr := range n.Attr { - if attr.Key == "content" { - res = []agents.ExternalImage{ - {URL: attr.Val}, - } - break + if attr.Key != "content" { + continue + } + if strings.Contains(attr.Val, artistIgnoredImage) { + log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val) + return res, nil + } + + res = []agents.ExternalImage{ + {URL: attr.Val}, } } return res, nil diff --git a/core/agents/lastfm/agent_test.go b/core/agents/lastfm/agent_test.go index 4476d592f..18e7facf2 100644 --- a/core/agents/lastfm/agent_test.go +++ b/core/agents/lastfm/agent_test.go @@ -393,4 +393,73 @@ var _ = Describe("lastfmAgent", func() { }) }) }) + + Describe("GetArtistImages", func() { + var agent *lastfmAgent + var apiClient *tests.FakeHttpClient + var httpClient *tests.FakeHttpClient + + BeforeEach(func() { + apiClient = &tests.FakeHttpClient{} + httpClient = &tests.FakeHttpClient{} + client := newClient("API_KEY", "SECRET", "pt", apiClient) + agent = lastFMConstructor(ds) + agent.client = client + agent.httpClient = httpClient + }) + + It("returns the artist image from the page", func() { + fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + apiClient.Res = http.Response{Body: fApi, StatusCode: 200} + + fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html") + httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + + images, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(HaveLen(1)) + Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png")) + }) + + It("returns empty list if image is the ignored default image", func() { + fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + apiClient.Res = http.Response{Body: fApi, StatusCode: 200} + + fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html") + httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + + images, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(BeEmpty()) + }) + + It("returns empty list if page has no meta tags", func() { + fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + apiClient.Res = http.Response{Body: fApi, StatusCode: 200} + + fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html") + httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + + images, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(BeEmpty()) + }) + + It("returns error if API call fails", func() { + apiClient.Err = errors.New("api error") + _, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("get artist info")) + }) + + It("returns error if scraper call fails", func() { + fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + apiClient.Res = http.Response{Body: fApi, StatusCode: 200} + + httpClient.Err = errors.New("scraper error") + _, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("get artist url")) + }) + }) }) diff --git a/tests/fixtures/lastfm.artist.page.html b/tests/fixtures/lastfm.artist.page.html new file mode 100644 index 000000000..1922e313b --- /dev/null +++ b/tests/fixtures/lastfm.artist.page.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.page.ignored.html b/tests/fixtures/lastfm.artist.page.ignored.html new file mode 100644 index 000000000..96eda2377 --- /dev/null +++ b/tests/fixtures/lastfm.artist.page.ignored.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.page.no_meta.html b/tests/fixtures/lastfm.artist.page.no_meta.html new file mode 100644 index 000000000..aa7b9c934 --- /dev/null +++ b/tests/fixtures/lastfm.artist.page.no_meta.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file From 0c3012bbbdf232e3aeffd461ee05422e6f83829d Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 19 Nov 2025 22:05:46 -0500 Subject: [PATCH 19/42] chore(deps): update Go dependencies to latest versions Signed-off-by: Deluan --- go.mod | 20 ++++++++++---------- go.sum | 40 ++++++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/go.mod b/go.mod index 5a6a99070..d80c900e9 100644 --- a/go.mod +++ b/go.mod @@ -57,16 +57,16 @@ require ( github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/tetratelabs/wazero v1.10.0 + github.com/tetratelabs/wazero v1.10.1 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.uber.org/goleak v1.3.0 - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 - golang.org/x/image v0.32.0 - golang.org/x/net v0.46.0 + golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 + golang.org/x/image v0.33.0 + golang.org/x/net v0.47.0 golang.org/x/sync v0.18.0 golang.org/x/sys v0.38.0 - golang.org/x/text v0.30.0 + golang.org/x/text v0.31.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 @@ -90,7 +90,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect + github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -128,10 +128,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect + golang.org/x/tools v0.39.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 7cda0ce8d..77c0cbb40 100644 --- a/go.sum +++ b/go.sum @@ -99,8 +99,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0= -github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE= +github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tetratelabs/wazero v1.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk= -github.com/tetratelabs/wazero v1.10.0/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU= +github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8= +github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -298,20 +298,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= -golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -323,8 +323,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -353,8 +353,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 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-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-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo= +golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -373,8 +373,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -384,8 +384,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= From 36fa869329ca4922635abcd4446bb5f9aebaae7f Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 20 Nov 2025 09:27:42 -0500 Subject: [PATCH 20/42] feat(scanner): improve error messages for cleanup operations in annotations, bookmarks, and tags Signed-off-by: Deluan --- persistence/sql_annotations.go | 2 +- persistence/sql_bookmarks.go | 4 ++-- persistence/tag_repository.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 6691b553c..98ade6e21 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -119,7 +119,7 @@ func (r sqlRepository) cleanAnnotations() error { del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") c, err := r.executeSQL(del) if err != nil { - return fmt.Errorf("error cleaning up annotations: %w", err) + return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err) } if c > 0 { log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c) diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index 52c4b8e9c..9164aed9d 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -148,10 +148,10 @@ func (r sqlRepository) cleanBookmarks() error { del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") c, err := r.executeSQL(del) if err != nil { - return fmt.Errorf("error cleaning up bookmarks: %w", err) + return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err) } if c > 0 { - log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c) + log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName) } return nil } diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index b224450ab..5bb8b3832 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -88,10 +88,10 @@ func (r *tagRepository) purgeUnused() error { `) c, err := r.executeSQL(del) if err != nil { - return fmt.Errorf("error purging unused tags: %w", err) + return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err) } if c > 0 { - log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c) + log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName) } return err } From 5c1662250179bff1e8996decf83934bda2adca7e Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 20 Nov 2025 10:38:40 -0500 Subject: [PATCH 21/42] chore(makefile): update golangci-lint version to v2.6.2 See comment https://github.com/navidrome/navidrome/commit/0c71842b12295dabfd3e14bfb5c8175312dde5fd#commitcomment-170969373 Signed-off-by: Deluan --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d80c900e9..f680bda51 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.25.4 +go 1.25 // 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 From 152f57e6424081164a61f5d5729923927b7fe91c Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 20 Nov 2025 10:38:54 -0500 Subject: [PATCH 22/42] chore(deps): update golangci-lint version to v2.6.2 Signed-off-by: Deluan --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2a60b7165..1de789c11 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop # Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib CROSS_TAGLIB_VERSION ?= 2.1.1-1 -GOLANGCI_LINT_VERSION ?= v2.5.0 +GOLANGCI_LINT_VERSION ?= v2.6.2 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") From 255ed1f8e2285c6dd1938c71225726b8e4765f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 21 Nov 2025 15:09:24 -0500 Subject: [PATCH 23/42] feat(deezer): Add artist bio, top tracks, related artists and language support (#4720) * feat(deezer): add functions to fetch related artists, biographies, and top tracks for an artist Signed-off-by: Deluan * feat(deezer): add language support for Deezer API client Signed-off-by: Deluan * fix(deezer): Use GraphQL API for translated biographies The previous implementation scraped the __DZR_APP_STATE__ from HTML, which only contained English content. The actual biography displayed on Deezer's website comes from their GraphQL API at pipe.deezer.com, which properly respects the Accept-Language header and returns translated content. This change: - Switches from HTML scraping to the GraphQL API - Uses Accept-Language header instead of URL path for language - Updates tests to match the new implementation - Removes unused HTML fixture file Signed-off-by: Deluan * refactor(deezer): move JWT token handling to a separate file for better organization Signed-off-by: Deluan * feat(deezer): enhance JWT token handling with expiration validation Signed-off-by: Deluan * refactor(deezer): change log level for unknown agent warnings from Warn to Debug Signed-off-by: Deluan * fix(deezer): reduce JWT token expiration buffer from 10 minutes to 1 minute Signed-off-by: Deluan --------- Signed-off-by: Deluan --- conf/configuration.go | 4 +- core/agents/agents.go | 2 +- core/agents/deezer/client.go | 141 ++++++++++- core/agents/deezer/client_auth.go | 101 ++++++++ core/agents/deezer/client_auth_test.go | 293 ++++++++++++++++++++++ core/agents/deezer/client_test.go | 135 +++++++++- core/agents/deezer/deezer.go | 53 +++- core/agents/deezer/responses.go | 35 +++ core/agents/deezer/responses_test.go | 31 +++ tests/fixtures/deezer.artist.bio.json | 9 + tests/fixtures/deezer.artist.related.json | 1 + tests/fixtures/deezer.artist.top.json | 1 + 12 files changed, 796 insertions(+), 10 deletions(-) create mode 100644 core/agents/deezer/client_auth.go create mode 100644 core/agents/deezer/client_auth_test.go create mode 100644 tests/fixtures/deezer.artist.bio.json create mode 100644 tests/fixtures/deezer.artist.related.json create mode 100644 tests/fixtures/deezer.artist.top.json diff --git a/conf/configuration.go b/conf/configuration.go index a9fee00e4..0ad81492a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -176,7 +176,8 @@ type spotifyOptions struct { } type deezerOptions struct { - Enabled bool + Enabled bool + Language string } type listenBrainzOptions struct { @@ -566,6 +567,7 @@ func setViperDefaults() { viper.SetDefault("spotify.id", "") viper.SetDefault("spotify.secret", "") viper.SetDefault("deezer.enabled", true) + viper.SetDefault("deezer.language", "en") viper.SetDefault("listenbrainz.enabled", true) viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY") diff --git a/core/agents/agents.go b/core/agents/agents.go index 4ec324b71..cb10d2c4c 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -87,7 +87,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent { } else if isPlugin { validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true}) } else { - log.Warn("Unknown agent ignored", "name", name) + log.Debug("Unknown agent ignored", "name", name) } } return validAgents diff --git a/core/agents/deezer/client.go b/core/agents/deezer/client.go index e75526d80..32d93bad6 100644 --- a/core/agents/deezer/client.go +++ b/core/agents/deezer/client.go @@ -1,6 +1,7 @@ package deezer import ( + bytes "bytes" "context" "encoding/json" "errors" @@ -9,11 +10,14 @@ import ( "net/http" "net/url" "strconv" + "strings" + "github.com/microcosm-cc/bluemonday" "github.com/navidrome/navidrome/log" ) const apiBaseURL = "https://api.deezer.com" +const authBaseURL = "https://auth.deezer.com" var ( ErrNotFound = errors.New("deezer: not found") @@ -25,10 +29,15 @@ type httpDoer interface { type client struct { httpDoer httpDoer + language string + jwt jwtToken } -func newClient(hc httpDoer) *client { - return &client{hc} +func newClient(hc httpDoer, language string) *client { + return &client{ + httpDoer: hc, + language: language, + } } func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) { @@ -53,7 +62,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A return results.Data, nil } -func (c *client) makeRequest(req *http.Request, response interface{}) error { +func (c *client) makeRequest(req *http.Request, response any) error { log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL) resp, err := c.httpDoer.Do(req) if err != nil { @@ -81,3 +90,129 @@ func (c *client) parseError(data []byte) error { } return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message) } + +func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) { + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil) + if err != nil { + return nil, err + } + + var results RelatedArtists + err = c.makeRequest(req, &results) + if err != nil { + return nil, err + } + + return results.Data, nil +} + +func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) { + params := url.Values{} + params.Add("limit", strconv.Itoa(limit)) + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil) + if err != nil { + return nil, err + } + req.URL.RawQuery = params.Encode() + + var results TopTracks + err = c.makeRequest(req, &results) + if err != nil { + return nil, err + } + + return results.Data, nil +} + +const pipeAPIURL = "https://pipe.deezer.com/api" + +var strictPolicy = bluemonday.StrictPolicy() + +func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) { + jwt, err := c.getJWT(ctx) + if err != nil { + return "", fmt.Errorf("deezer: failed to get JWT: %w", err) + } + + query := map[string]any{ + "operationName": "ArtistBio", + "variables": map[string]any{ + "artistId": strconv.Itoa(artistID), + }, + "query": `query ArtistBio($artistId: String!) { + artist(artistId: $artistId) { + bio { + full + } + } + }`, + } + + body, err := json.Marshal(query) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept-Language", c.language) + req.Header.Set("Authorization", "Bearer "+jwt) + + log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language) + resp, err := c.httpDoer.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + type graphQLResponse struct { + Data struct { + Artist struct { + Bio struct { + Full string `json:"full"` + } `json:"bio"` + } `json:"artist"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } + } + + var result graphQLResponse + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err) + } + + if len(result.Errors) > 0 { + var errs []error + for m := range result.Errors { + errs = append(errs, errors.New(result.Errors[m].Message)) + } + err := errors.Join(errs...) + return "", fmt.Errorf("deezer: GraphQL error: %w", err) + } + + if result.Data.Artist.Bio.Full == "" { + return "", errors.New("deezer: biography not found") + } + + return cleanBio(result.Data.Artist.Bio.Full), nil +} + +func cleanBio(bio string) string { + bio = strings.ReplaceAll(bio, "

", "\n") + return strictPolicy.Sanitize(bio) +} diff --git a/core/agents/deezer/client_auth.go b/core/agents/deezer/client_auth.go new file mode 100644 index 000000000..c88c2bcb6 --- /dev/null +++ b/core/agents/deezer/client_auth.go @@ -0,0 +1,101 @@ +package deezer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/navidrome/navidrome/log" +) + +type jwtToken struct { + token string + expiresAt time.Time + mu sync.RWMutex +} + +func (j *jwtToken) get() (string, bool) { + j.mu.RLock() + defer j.mu.RUnlock() + if time.Now().Before(j.expiresAt) { + return j.token, true + } + return "", false +} + +func (j *jwtToken) set(token string, expiresIn time.Duration) { + j.mu.Lock() + defer j.mu.Unlock() + j.token = token + j.expiresAt = time.Now().Add(expiresIn) +} + +func (c *client) getJWT(ctx context.Context) (string, error) { + // Check if we have a valid cached token + if token, valid := c.jwt.get(); valid { + return token, nil + } + + // Fetch a new anonymous token + req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpDoer.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + type authResponse struct { + JWT string `json:"jwt"` + } + + var result authResponse + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("deezer: failed to parse auth response: %w", err) + } + + if result.JWT == "" { + return "", errors.New("deezer: no JWT token in response") + } + + // Parse JWT to get actual expiration time + token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err) + } + + // Calculate TTL with a 1-minute buffer for clock skew and network delays + expiresAt := token.Expiration() + if expiresAt.IsZero() { + return "", errors.New("deezer: JWT token has no expiration time") + } + + ttl := time.Until(expiresAt) - 1*time.Minute + if ttl <= 0 { + return "", errors.New("deezer: JWT token already expired or expires too soon") + } + + c.jwt.set(result.JWT, ttl) + log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl) + + return result.JWT, nil +} diff --git a/core/agents/deezer/client_auth_test.go b/core/agents/deezer/client_auth_test.go new file mode 100644 index 000000000..b0c2d195d --- /dev/null +++ b/core/agents/deezer/client_auth_test.go @@ -0,0 +1,293 @@ +package deezer + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v2/jwt" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("JWT Authentication", func() { + var httpClient *fakeHttpClient + var client *client + var ctx context.Context + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + client = newClient(httpClient, "en") + ctx = context.Background() + }) + + Describe("getJWT", func() { + Context("with a valid JWT response", func() { + It("successfully fetches and caches a JWT token", func() { + testJWT := createTestJWT(5 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), + }) + + token, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token).To(Equal(testJWT)) + }) + + It("returns the cached token on subsequent calls", func() { + testJWT := createTestJWT(5 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), + }) + + // First call should fetch from API + token1, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token1).To(Equal(testJWT)) + Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous")) + + // Second call should return cached token without hitting API + httpClient.lastRequest = nil // Clear last request to verify no new request is made + token2, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token2).To(Equal(testJWT)) + Expect(httpClient.lastRequest).To(BeNil()) // No new request made + }) + + It("parses the JWT expiration time correctly", func() { + expectedExpiration := time.Now().Add(5 * time.Minute) + testToken, err := jwt.NewBuilder(). + Expiration(expectedExpiration). + Build() + Expect(err).To(BeNil()) + testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature()) + Expect(err).To(BeNil()) + + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))), + }) + + token, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token).ToNot(BeEmpty()) + + // Verify the token is cached until close to expiration + // The cache should expire 1 minute before the JWT expires + expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute) + Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second)) + }) + }) + + Context("with JWT tokens that expire soon", func() { + It("rejects tokens that expire in less than 1 minute", func() { + // Create a token that expires in 30 seconds (less than 1-minute buffer) + testJWT := createTestJWT(30 * time.Second) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) + }) + + It("rejects already expired tokens", func() { + // Create a token that expired 1 minute ago + testJWT := createTestJWT(-1 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) + }) + + It("accepts tokens that expire in more than 1 minute", func() { + // Create a token that expires in 2 minutes (just over the 1-minute buffer) + testJWT := createTestJWT(2 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), + }) + + token, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token).ToNot(BeEmpty()) + }) + }) + + Context("with invalid responses", func() { + It("handles HTTP error responses", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 500, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get JWT token")) + }) + + It("handles malformed JSON responses", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse auth response")) + }) + + It("handles responses with empty JWT field", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("deezer: no JWT token in response")) + }) + + It("handles invalid JWT tokens", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse JWT token")) + }) + + It("rejects JWT tokens without expiration", func() { + // Create a JWT without expiration claim + testToken, err := jwt.NewBuilder(). + Claim("custom", "value"). + Build() + Expect(err).To(BeNil()) + + // Verify token has no expiration + Expect(testToken.Expiration().IsZero()).To(BeTrue()) + + testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature()) + Expect(err).To(BeNil()) + + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))), + }) + + _, err = client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time")) + }) + }) + + Context("token caching behavior", func() { + It("fetches a new token when the cached token expires", func() { + // First token expires in 5 minutes + firstJWT := createTestJWT(5 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))), + }) + + token1, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token1).To(Equal(firstJWT)) + + // Manually expire the cached token + client.jwt.expiresAt = time.Now().Add(-1 * time.Second) + + // Second token with different expiration (10 minutes) + secondJWT := createTestJWT(10 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))), + }) + + token2, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token2).To(Equal(secondJWT)) + Expect(token2).ToNot(Equal(token1)) + }) + }) + }) + + Describe("jwtToken cache", func() { + var cache *jwtToken + + BeforeEach(func() { + cache = &jwtToken{} + }) + + It("returns false for expired tokens", func() { + cache.set("test-token", -1*time.Second) // Already expired + token, valid := cache.get() + Expect(valid).To(BeFalse()) + Expect(token).To(BeEmpty()) + }) + + It("returns true for valid tokens", func() { + cache.set("test-token", 4*time.Minute) + token, valid := cache.get() + Expect(valid).To(BeTrue()) + Expect(token).To(Equal("test-token")) + }) + + It("is thread-safe for concurrent access", func() { + wg := sync.WaitGroup{} + + // Writer goroutine + wg.Go(func() { + for i := 0; i < 100; i++ { + cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour) + time.Sleep(1 * time.Millisecond) + } + }) + + // Reader goroutine + wg.Go(func() { + for i := 0; i < 100; i++ { + cache.get() + time.Sleep(1 * time.Millisecond) + } + }) + + // Wait for both goroutines to complete + wg.Wait() + + // Verify final state is valid + token, valid := cache.get() + Expect(valid).To(BeTrue()) + Expect(token).To(HavePrefix("token-")) + }) + }) +}) + +// createTestJWT creates a valid JWT token for testing purposes +func createTestJWT(expiresIn time.Duration) string { + token, err := jwt.NewBuilder(). + Expiration(time.Now().Add(expiresIn)). + Build() + if err != nil { + panic(fmt.Sprintf("failed to create test JWT: %v", err)) + } + signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature()) + if err != nil { + panic(fmt.Sprintf("failed to sign test JWT: %v", err)) + } + return string(signed) +} diff --git a/core/agents/deezer/client_test.go b/core/agents/deezer/client_test.go index 5e47460d4..7e4f7a49f 100644 --- a/core/agents/deezer/client_test.go +++ b/core/agents/deezer/client_test.go @@ -2,10 +2,11 @@ package deezer import ( "bytes" - "context" + "fmt" "io" "net/http" "os" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -17,7 +18,7 @@ var _ = Describe("client", func() { BeforeEach(func() { httpClient = &fakeHttpClient{} - client = newClient(httpClient) + client = newClient(httpClient, "en") }) Describe("ArtistImages", func() { @@ -26,7 +27,7 @@ var _ = Describe("client", func() { Expect(err).To(BeNil()) httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200}) - artists, err := client.searchArtists(context.TODO(), "Michael Jackson", 20) + artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) Expect(err).To(BeNil()) Expect(artists).To(HaveLen(17)) Expect(artists[0].Name).To(Equal("Michael Jackson")) @@ -39,10 +40,136 @@ var _ = Describe("client", func() { Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)), }) - _, err := client.searchArtists(context.TODO(), "Michael Jackson", 20) + _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) Expect(err).To(MatchError(ErrNotFound)) }) }) + + Describe("ArtistBio", func() { + BeforeEach(func() { + // Mock the JWT token endpoint with a valid JWT that expires in 5 minutes + testJWT := createTestJWT(5 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))), + }) + }) + + It("returns artist bio from a successful request", func() { + f, err := os.Open("tests/fixtures/deezer.artist.bio.json") + Expect(err).To(BeNil()) + httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) + + bio, err := client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(BeNil()) + Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel")) + Expect(bio).ToNot(ContainSubstring("

")) + Expect(bio).ToNot(ContainSubstring("

")) + }) + + It("uses the configured language", func() { + client = newClient(httpClient, "fr") + // Mock JWT token for the new client instance with a valid JWT + testJWT := createTestJWT(5 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))), + }) + f, err := os.Open("tests/fixtures/deezer.artist.bio.json") + Expect(err).To(BeNil()) + httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) + + _, err = client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(BeNil()) + Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr")) + }) + + It("includes the JWT token in the request", func() { + f, err := os.Open("tests/fixtures/deezer.artist.bio.json") + Expect(err).To(BeNil()) + httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) + + _, err = client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(BeNil()) + // Verify that the Authorization header has the Bearer token format + authHeader := httpClient.lastRequest.Header.Get("Authorization") + Expect(authHeader).To(HavePrefix("Bearer ")) + Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars + }) + + It("handles GraphQL errors", func() { + errorResponse := `{ + "data": { + "artist": { + "bio": { + "full": "" + } + } + }, + "errors": [ + { + "message": "Artist not found" + }, + { + "message": "Invalid artist ID" + } + ] + }` + httpClient.mock("https://pipe.deezer.com/api", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(errorResponse)), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 999) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("GraphQL error")) + Expect(err.Error()).To(ContainSubstring("Artist not found")) + Expect(err.Error()).To(ContainSubstring("Invalid artist ID")) + }) + + It("handles empty biography", func() { + emptyBioResponse := `{ + "data": { + "artist": { + "bio": { + "full": "" + } + } + } + }` + httpClient.mock("https://pipe.deezer.com/api", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(MatchError("deezer: biography not found")) + }) + + It("handles JWT token fetch failure", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 500, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get JWT")) + }) + + It("handles JWT token that expires too soon", func() { + // Create a JWT that expires in 30 seconds (less than the 1-minute buffer) + expiredJWT := createTestJWT(30 * time.Second) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) + }) + }) }) type fakeHttpClient struct { diff --git a/core/agents/deezer/deezer.go b/core/agents/deezer/deezer.go index 8cabfbcfb..8f3e505ec 100644 --- a/core/agents/deezer/deezer.go +++ b/core/agents/deezer/deezer.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/slice" ) const deezerAgentName = "deezer" @@ -32,7 +33,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface { Timeout: consts.DefaultHttpClientTimeOut, } cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) - agent.client = newClient(cachedHttpClient) + agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language) return agent } @@ -88,6 +89,56 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e return &artists[0], err } +func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) { + artist, err := s.searchArtist(ctx, name) + if err != nil { + return nil, err + } + + related, err := s.client.getRelatedArtists(ctx, artist.ID) + if err != nil { + return nil, err + } + + res := slice.Map(related, func(r Artist) agents.Artist { + return agents.Artist{ + Name: r.Name, + } + }) + if len(res) > limit { + res = res[:limit] + } + return res, nil +} + +func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) { + artist, err := s.searchArtist(ctx, artistName) + if err != nil { + return nil, err + } + + tracks, err := s.client.getTopTracks(ctx, artist.ID, count) + if err != nil { + return nil, err + } + + res := slice.Map(tracks, func(r Track) agents.Song { + return agents.Song{ + Name: r.Title, + } + }) + return res, nil +} + +func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) { + artist, err := s.searchArtist(ctx, name) + if err != nil { + return "", err + } + + return s.client.getArtistBio(ctx, artist.ID) +} + func init() { conf.AddHook(func() { if conf.Server.Deezer.Enabled { diff --git a/core/agents/deezer/responses.go b/core/agents/deezer/responses.go index 112fe28ec..266c44c62 100644 --- a/core/agents/deezer/responses.go +++ b/core/agents/deezer/responses.go @@ -29,3 +29,38 @@ type Error struct { Code int `json:"code"` } `json:"error"` } + +type RelatedArtists struct { + Data []Artist `json:"data"` + Total int `json:"total"` +} + +type TopTracks struct { + Data []Track `json:"data"` + Total int `json:"total"` + Next string `json:"next"` +} + +type Track struct { + ID int `json:"id"` + Title string `json:"title"` + Link string `json:"link"` + Duration int `json:"duration"` + Rank int `json:"rank"` + Preview string `json:"preview"` + Artist Artist `json:"artist"` + Album Album `json:"album"` + Contributors []Artist `json:"contributors"` +} + +type Album struct { + ID int `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` + CoverSmall string `json:"cover_small"` + CoverMedium string `json:"cover_medium"` + CoverBig string `json:"cover_big"` + CoverXl string `json:"cover_xl"` + Tracklist string `json:"tracklist"` + Type string `json:"type"` +} diff --git a/core/agents/deezer/responses_test.go b/core/agents/deezer/responses_test.go index 95a7f43f4..a9de5c5fb 100644 --- a/core/agents/deezer/responses_test.go +++ b/core/agents/deezer/responses_test.go @@ -35,4 +35,35 @@ var _ = Describe("Responses", func() { Expect(errorResp.Error.Message).To(Equal("Missing parameters: q")) }) }) + + Describe("Related Artists", func() { + It("parses the related artists response correctly", func() { + var resp RelatedArtists + body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json") + Expect(err).To(BeNil()) + err = json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.Data).To(HaveLen(20)) + justice := resp.Data[0] + Expect(justice.Name).To(Equal("Justice")) + Expect(justice.ID).To(Equal(6404)) + }) + }) + + Describe("Top Tracks", func() { + It("parses the top tracks response correctly", func() { + var resp TopTracks + body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json") + Expect(err).To(BeNil()) + err = json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.Data).To(HaveLen(5)) + track := resp.Data[0] + Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)")) + Expect(track.ID).To(Equal(67238732)) + Expect(track.Album.Title).To(Equal("Random Access Memories")) + }) + }) }) diff --git a/tests/fixtures/deezer.artist.bio.json b/tests/fixtures/deezer.artist.bio.json new file mode 100644 index 000000000..80e439bae --- /dev/null +++ b/tests/fixtures/deezer.artist.bio.json @@ -0,0 +1,9 @@ +{ + "data": { + "artist": { + "bio": { + "full": "

Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.

" + } + } + } +} diff --git a/tests/fixtures/deezer.artist.related.json b/tests/fixtures/deezer.artist.related.json new file mode 100644 index 000000000..2a55b303e --- /dev/null +++ b/tests/fixtures/deezer.artist.related.json @@ -0,0 +1 @@ +{"data":[{"id":6404,"name":"Justice","link":"https:\/\/www.deezer.com\/artist\/6404","picture":"https:\/\/api.deezer.com\/artist\/6404\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/1000x1000-000000-80-0-0.jpg","nb_album":41,"nb_fan":774236,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/6404\/top?limit=50","type":"artist"},{"id":2049,"name":"Cassius","link":"https:\/\/www.deezer.com\/artist\/2049","picture":"https:\/\/api.deezer.com\/artist\/2049\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":127692,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2049\/top?limit=50","type":"artist"},{"id":2318,"name":"Etienne de Cr\u00e9cy","link":"https:\/\/www.deezer.com\/artist\/2318","picture":"https:\/\/api.deezer.com\/artist\/2318\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/1000x1000-000000-80-0-0.jpg","nb_album":58,"nb_fan":104626,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2318\/top?limit=50","type":"artist"},{"id":72041,"name":"Yuksek","link":"https:\/\/www.deezer.com\/artist\/72041","picture":"https:\/\/api.deezer.com\/artist\/72041\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/1000x1000-000000-80-0-0.jpg","nb_album":102,"nb_fan":115772,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/72041\/top?limit=50","type":"artist"},{"id":81,"name":"The Chemical Brothers","link":"https:\/\/www.deezer.com\/artist\/81","picture":"https:\/\/api.deezer.com\/artist\/81\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/1000x1000-000000-80-0-0.jpg","nb_album":83,"nb_fan":1433333,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/81\/top?limit=50","type":"artist"},{"id":3771,"name":"Mr. Oizo","link":"https:\/\/www.deezer.com\/artist\/3771","picture":"https:\/\/api.deezer.com\/artist\/3771\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/1000x1000-000000-80-0-0.jpg","nb_album":31,"nb_fan":172085,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/3771\/top?limit=50","type":"artist"},{"id":9905,"name":"Alex Gopher","link":"https:\/\/www.deezer.com\/artist\/9905","picture":"https:\/\/api.deezer.com\/artist\/9905\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/1000x1000-000000-80-0-0.jpg","nb_album":46,"nb_fan":10430,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/9905\/top?limit=50","type":"artist"},{"id":7914,"name":"Demon","link":"https:\/\/www.deezer.com\/artist\/7914","picture":"https:\/\/api.deezer.com\/artist\/7914\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/1000x1000-000000-80-0-0.jpg","nb_album":21,"nb_fan":9286,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7914\/top?limit=50","type":"artist"},{"id":8937,"name":"SebastiAn","link":"https:\/\/www.deezer.com\/artist\/8937","picture":"https:\/\/api.deezer.com\/artist\/8937\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/1000x1000-000000-80-0-0.jpg","nb_album":48,"nb_fan":74884,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/8937\/top?limit=50","type":"artist"},{"id":2508,"name":"Digitalism","link":"https:\/\/www.deezer.com\/artist\/2508","picture":"https:\/\/api.deezer.com\/artist\/2508\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/1000x1000-000000-80-0-0.jpg","nb_album":79,"nb_fan":158628,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2508\/top?limit=50","type":"artist"},{"id":11703,"name":"Alan Braxe","link":"https:\/\/www.deezer.com\/artist\/11703","picture":"https:\/\/api.deezer.com\/artist\/11703\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":12595,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11703\/top?limit=50","type":"artist"},{"id":574,"name":"Para One","link":"https:\/\/www.deezer.com\/artist\/574","picture":"https:\/\/api.deezer.com\/artist\/574\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/1000x1000-000000-80-0-0.jpg","nb_album":40,"nb_fan":30828,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/574\/top?limit=50","type":"artist"},{"id":4397,"name":"Kojak","link":"https:\/\/www.deezer.com\/artist\/4397","picture":"https:\/\/api.deezer.com\/artist\/4397\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/1000x1000-000000-80-0-0.jpg","nb_album":55,"nb_fan":1522,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4397\/top?limit=50","type":"artist"},{"id":12439,"name":"Busy P","link":"https:\/\/www.deezer.com\/artist\/12439","picture":"https:\/\/api.deezer.com\/artist\/12439\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/1000x1000-000000-80-0-0.jpg","nb_album":12,"nb_fan":65585,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/12439\/top?limit=50","type":"artist"},{"id":11656979,"name":"Mr Flash","link":"https:\/\/www.deezer.com\/artist\/11656979","picture":"https:\/\/api.deezer.com\/artist\/11656979\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":769,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11656979\/top?limit=50","type":"artist"},{"id":76,"name":"Fatboy Slim","link":"https:\/\/www.deezer.com\/artist\/76","picture":"https:\/\/api.deezer.com\/artist\/76\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/1000x1000-000000-80-0-0.jpg","nb_album":76,"nb_fan":1231355,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/76\/top?limit=50","type":"artist"},{"id":11265,"name":"Lifelike","link":"https:\/\/www.deezer.com\/artist\/11265","picture":"https:\/\/api.deezer.com\/artist\/11265\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/1000x1000-000000-80-0-0.jpg","nb_album":38,"nb_fan":8316,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11265\/top?limit=50","type":"artist"},{"id":2048,"name":"Groove Armada","link":"https:\/\/www.deezer.com\/artist\/2048","picture":"https:\/\/api.deezer.com\/artist\/2048\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/1000x1000-000000-80-0-0.jpg","nb_album":92,"nb_fan":173879,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2048\/top?limit=50","type":"artist"},{"id":71708,"name":"Surkin","link":"https:\/\/www.deezer.com\/artist\/71708","picture":"https:\/\/api.deezer.com\/artist\/71708\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/1000x1000-000000-80-0-0.jpg","nb_album":15,"nb_fan":23101,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/71708\/top?limit=50","type":"artist"},{"id":166713,"name":"Fred Falke","link":"https:\/\/www.deezer.com\/artist\/166713","picture":"https:\/\/api.deezer.com\/artist\/166713\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/1000x1000-000000-80-0-0.jpg","nb_album":67,"nb_fan":9688,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/166713\/top?limit=50","type":"artist"}],"total":20} \ No newline at end of file diff --git a/tests/fixtures/deezer.artist.top.json b/tests/fixtures/deezer.artist.top.json new file mode 100644 index 000000000..e3f22a1aa --- /dev/null +++ b/tests/fixtures/deezer.artist.top.json @@ -0,0 +1 @@ +{"data":[{"id":67238732,"readable":true,"title":"Instant Crush (feat. Julian Casablancas)","title_short":"Instant Crush","title_version":"(feat. Julian Casablancas)","link":"https:\/\/www.deezer.com\/track\/67238732","duration":337,"rank":944042,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3*~data=user_id=0,application_id=42~hmac=66213cecf953c7ef8b4d89e3539a1355d318679c5ab54cac2007d4effa6c3bf4","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":295821,"name":"Julian Casablancas","link":"https:\/\/www.deezer.com\/artist\/295821","share":"https:\/\/www.deezer.com\/artist\/295821?utm_source=deezer&utm_content=artist-295821&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/295821\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/295821\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3135553,"readable":true,"title":"One More Time","title_short":"One More Time","title_version":"","link":"https:\/\/www.deezer.com\/track\/3135553","duration":320,"rank":888570,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3*~data=user_id=0,application_id=42~hmac=0824ec7ad045b82c04904fcd5f2a8ec2175acbe3d1649030d457023fdef45620","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":302127,"title":"Discovery","cover":"https:\/\/api.deezer.com\/album\/302127\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/1000x1000-000000-80-0-0.jpg","md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","tracklist":"https:\/\/api.deezer.com\/album\/302127\/tracks","type":"album"},"type":"track"},{"id":66609426,"readable":true,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(Radio Edit - feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/66609426","duration":248,"rank":952197,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3*~data=user_id=0,application_id=42~hmac=c6dfe58571df62f41e7b326dd9afebf87015541c06a521ebc88fc18671d8d06d","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6516139,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","cover":"https:\/\/api.deezer.com\/album\/6516139\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/1000x1000-000000-80-0-0.jpg","md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","tracklist":"https:\/\/api.deezer.com\/album\/6516139\/tracks","type":"album"},"type":"track"},{"id":67238735,"readable":true,"title":"Get Lucky (feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/67238735","duration":367,"rank":873875,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3*~data=user_id=0,application_id=42~hmac=92002e6bade5ff82dd44751e8998beaa60844210df1d73b8f1bf7dafb02dc5c3","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3129775,"readable":true,"title":"Around the World","title_short":"Around the World","title_version":"","link":"https:\/\/www.deezer.com\/track\/3129775","duration":429,"rank":829911,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3*~data=user_id=0,application_id=42~hmac=9b7aa12b647cabd3219779e0270e51e639dc326442071fceb6d723c331059a67","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"b870579c8650cd59b1cce656dde2ef17","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":301775,"title":"Homework","cover":"https:\/\/api.deezer.com\/album\/301775\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/1000x1000-000000-80-0-0.jpg","md5_image":"b870579c8650cd59b1cce656dde2ef17","tracklist":"https:\/\/api.deezer.com\/album\/301775\/tracks","type":"album"},"type":"track"}],"total":100,"next":"https:\/\/api.deezer.com\/artist\/27\/top?index=5"} \ No newline at end of file From 67c4e249570c1928f3559a694427a6ce34adda67 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 21 Nov 2025 15:26:30 -0500 Subject: [PATCH 24/42] fix(scanner): defer artwork PreCache calls until after transaction commits The CacheWarmer was failing with data not found errors because PreCache was being called inside the database transaction before the data was committed. The CacheWarmer runs in a separate goroutine with its own database context and could not access the uncommitted data due to transaction isolation. Changed the persistChanges method in phase_1_folders.go to collect artwork IDs during the transaction and only call PreCache after the transaction successfully commits. This ensures the artwork data is visible to the CacheWarmer when it attempts to retrieve and cache the images. The fix eliminates the data not found errors and allows the cache warmer to properly pre-cache album and artist artwork during library scanning. Signed-off-by: Deluan --- scanner/phase_1_folders.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 2f6b62b2d..329029951 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -324,6 +324,9 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) defer p.measure(entry)() p.state.changesDetected.Store(true) + // Collect artwork IDs to pre-cache after the transaction commits + var artworkIDs []model.ArtworkID + err := p.ds.WithTx(func(tx model.DataStore) error { // Instantiate all repositories just once per folder folderRepo := tx.Folder(p.ctx) @@ -362,7 +365,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - entry.job.cw.PreCache(entry.artists[i].CoverArtID()) + artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID()) } } @@ -374,7 +377,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.albums[i].Name != consts.UnknownAlbum { - entry.job.cw.PreCache(entry.albums[i].CoverArtID()) + artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID()) } } @@ -411,6 +414,14 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) if err != nil { log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err) } + + // Pre-cache artwork after the transaction commits successfully + if err == nil { + for _, artID := range artworkIDs { + entry.job.cw.PreCache(artID) + } + } + return entry, err } From f6b2ab57262c0c6c411a1002be8cc31c75f270b6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 21 Nov 2025 22:23:38 -0500 Subject: [PATCH 25/42] feat(ui): add loading state to artist action buttons for improved user experience Signed-off-by: Deluan --- ui/src/artist/ArtistActions.jsx | 46 +++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index c33ee892b..8eebe6499 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import { useDispatch } from 'react-redux' -import { useMediaQuery } from '@material-ui/core' +import { useMediaQuery, CircularProgress } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { Button, @@ -45,6 +45,12 @@ const useStyles = makeStyles((theme) => ({ }, })) +const LoadingButton = ({ loading, icon, ...rest }) => ( + +) + const ArtistActions = ({ className, record, ...rest }) => { const dispatch = useDispatch() const translate = useTranslate() @@ -52,34 +58,45 @@ const ArtistActions = ({ className, record, ...rest }) => { const notify = useNotify() const classes = useStyles() const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const [loadingAction, setLoadingAction] = React.useState(null) + const isLoading = !!loadingAction const handlePlay = React.useCallback(async () => { + setLoadingAction('play') try { await playTopSongs(dispatch, notify, record.name) } catch (e) { // eslint-disable-next-line no-console console.error('Error fetching top songs for artist:', e) notify('ra.page.error', 'warning') + } finally { + setLoadingAction(null) } }, [dispatch, notify, record]) const handleShuffle = React.useCallback(async () => { + setLoadingAction('shuffle') try { await playShuffle(dataProvider, dispatch, record.id) } catch (e) { // eslint-disable-next-line no-console console.error('Error fetching songs for shuffle:', e) notify('ra.page.error', 'warning') + } finally { + setLoadingAction(null) } }, [dataProvider, dispatch, record, notify]) const handleRadio = React.useCallback(async () => { + setLoadingAction('radio') try { await playSimilar(dispatch, notify, record.id) } catch (e) { // eslint-disable-next-line no-console console.error('Error starting radio for artist:', e) notify('ra.page.error', 'warning') + } finally { + setLoadingAction(null) } }, [dispatch, notify, record]) @@ -88,30 +105,33 @@ const ArtistActions = ({ className, record, ...rest }) => { className={`${className} ${classes.toolbar}`} {...sanitizeListRestProps(rest)} > - - - + disabled={isLoading} + loading={loadingAction === 'radio'} + icon={} + /> ) } From 2451e9e7aeca3040ce463c86351352b61121ca6f Mon Sep 17 00:00:00 2001 From: Stephan Wahlen <44159957+metalheim@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:23:02 +0100 Subject: [PATCH 26/42] feat(ui): add AMusic (Apple Music inspired) theme (#4723) * first show at AMuisc Theme * prettier * fix Duplicate key 'MuiButton' * fix file name * Update amusic.js * Add styles for NDAlbumGridView in amusic.js * Fix MuiToolbar background property in amusic.js * Fix syntax error in amusic.js background property * run prettier * fix banded table styling and more * more styling to player - fix some appearances of green in queue - match queue styling to rest of theme - round albumart in player and prevent rotation * fix queue panel background and border to make it stand out more against the background * fix stray comma and lint+prettier * queue hover still green and player preview image not rounded properly * Update amusic.css.js * more mobile color fixes * artist page * prettier * rounded art in albumgridview * small tweaks to colors and radiuses * artist and album heading * external links colors * unify font colors + albumgrid corner radius * get rid of queue hover green * unify colors in player same red shades as primary * mobile player floating panel background shade of green * unify border colors and attempt to get album cover corner radius working * final touches * Update amusic.css.js * fix invisible button color fir muibutton Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix css syntax on player queue color overrides Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * remove unused MuiTableHead * sort theme list in index.js alphabetically * remove unused properties * Revert "fix css syntax on player queue color overrides" This reverts commit 503bba321d958aed5251667c58214822ceb70f59. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ui/src/themes/amusic.css.js | 89 ++++++++++++++++ ui/src/themes/amusic.js | 197 ++++++++++++++++++++++++++++++++++++ ui/src/themes/index.js | 2 + 3 files changed, 288 insertions(+) create mode 100644 ui/src/themes/amusic.css.js create mode 100644 ui/src/themes/amusic.js diff --git a/ui/src/themes/amusic.css.js b/ui/src/themes/amusic.css.js new file mode 100644 index 000000000..05709dc1e --- /dev/null +++ b/ui/src/themes/amusic.css.js @@ -0,0 +1,89 @@ +const stylesheet = ` +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #D60017 +} +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #ff4e6b +} +.react-jinke-music-player-main ::-webkit-scrollbar-thumb, +.react-jinke-music-player-mobile-progress .rc-slider-handle, +.react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #ff4e6b +} +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #ff4e6b +} +.audio-lists-panel-content .audio-item.playing, +.react-jinke-music-player-main .audio-item.playing svg, +.react-jinke-music-player-main .group player-delete { + color: #ff4e6b +} +.audio-lists-panel-content .audio-item:hover, +.audio-lists-panel-content .audio-item:hover svg +.audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg{ + color: #D60017 +} +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #ff4e6b !important +} +.react-jinke-music-player-main .lyric-btn, +.react-jinke-music-player-main .lyric-btn-active svg{ + color: #ff4e6b !important +} +.react-jinke-music-player-main .lyric-btn-active { + color: #D60017 !important +} +.react-jinke-music-player-main .loading svg { + color: #ff4e6b !important +} +.react-jinke-music-player .music-player-controller .music-player-controller-setting{ + background: #ff4e6b4d +} +.react-jinke-music-player-main .music-player-lyric{ + color: #ff4e6b !important; + text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000 +} +.react-jinke-music-player-main .music-player-panel, +.react-jinke-music-player-mobile, +.ril__outer{ + background-color: #1f1f1f; + border: 1px solid #fff1; +} +.ril__toolbar{ + background-color: #1d1d1d +} +.ril__toolbarItem{ + font-size: 100%; + color: #eee +} +.audio-lists-panel{ + background-color: #1f1f1f; + border: 1px solid #fff1; + border-radius: 6px 6px 0 0; +} +.react-jinke-music-player-main .music-player-panel .panel-content .img-rotate, +.react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover, +.react-jinke-music-player-mobile-cover { + border-radius: 6px !important; + animation-duration: 0s !important +} +.react-jinke-music-player-main .music-player-panel .panel-content .img-content{ + width: 60px; + height: 60px +} +.react-jinke-music-player-main .songTitle{ + color: #eee +} +.react-jinke-music-player .music-player-controller{ + color: #ff4e6b +} +.audio-lists-panel-mobile .audio-item:not(.audio-lists-panel-sortable-highlight-bg){ + background: unset +} +.lastfm-icon, +.musicbrainz-icon{ + color: #eee +} +` +export default stylesheet diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js new file mode 100644 index 000000000..598b7b7fa --- /dev/null +++ b/ui/src/themes/amusic.js @@ -0,0 +1,197 @@ +import stylesheet from './amusic.css.js' + +export default { + themeName: 'AMusic', + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, Apple Color Emoji, SF Pro, SF Pro Icons, Helvetica Neue, Helvetica, Arial, sans-serif', + h6: { + fontSize: '1rem', // AppBar title + }, + h5: { + fontSize: '2em', + fontWeight: '600', + }, + }, + palette: { + primary: { + main: '#ff4e6b', + }, + secondary: { + main: '#D60017', + contrastText: '#eee', + }, + background: { + default: '#1a1a1a', + paper: '#1a1a1a', + }, + type: 'dark', + }, + overrides: { + MuiFormGroup: { + root: { + color: 'white', + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: '#1d1d1d !important', + boxShadow: 'none', + borderBottom: '1px solid #fff1', + }, + colorSecondary: { + color: '#eee', + }, + }, + MuiDrawer: { + root: { + background: '#1d1d1d', + borderRight: '1px solid #fff1', + }, + }, + MuiToolbar: { + root: { + background: 'transparent !important', + }, + }, + MuiCardMedia: { + img: { + borderRadius: '10px', + boxShadow: '5px 5px 20px #111', + }, + }, + MuiButton: { + root: { + background: '#D60017', + color: '#fff', + borderRadius: '6px', + paddingRight: '0.5rem', + paddingLeft: '0.5rem', + marginLeft: '0.5rem', + marginBottom: '0.5rem', + textTransform: 'capitalize', + fontWeight: 600, + }, + textPrimary: { + color: '#eee', + }, + textSecondary: { + color: '#eee', + backgroundColor: '#ff4e6b', + }, + textSizeSmall: { + fontSize: '0.8rem', + paddingRight: '0.5rem', + paddingLeft: '0.5rem', + }, + label: { + paddingRight: '1rem', + paddingLeft: '0.7rem', + }, + }, + MuiListItemIcon: { + root: { + color: '#ff4e6b', + }, + }, + MuiChip: { + root: { + borderRadius: '6px', + }, + }, + MuiIconButton: { + root: { + color: '#ff4e6b', + }, + }, + MuiTableBody: { + root: { + '&>tr:nth-child(odd)': { + background: 'rgba(255, 255, 255, 0.025)', + }, + }, + }, + MuiTableRow: { + root: { + background: 'transparent', + }, + }, + MuiTableCell: { + root: { + borderBottom: '0 none !important', + padding: '10px !important', + color: '#b3b3b3 !important', + }, + head: { + color: '#b3b3b3 !important', + }, + }, + MuiMenuItem: { + root: { + fontSize: '0.875rem', + borderRadius: '10px', + color: '#eee', + }, + }, + NDAlbumGridView: { + albumName: { + color: '#eee', + }, + albumSubtitle: { + color: '#ccc', + }, + albumPlayButton: { + color: '#ff4e6b !important', + }, + albumArtistName: { + color: '#ff4e6b !important', + }, + cover: { + borderRadius: '10px !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#D60017', + }, + welcome: { + color: '#eee', + }, + card: { + minWidth: 300, + backgroundColor: '#1d1d1d', + }, + }, + MuiPaper: { + elevation1: { + boxShadow: 'none', + }, + root: { + color: '#eee', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: '#1a1a1a', + }, + artistName: { + fontWeight: '600', + fontSize: '2em', + }, + }, + NDDesktopArtistDetails: { + artistName: { + fontWeight: '600', + fontSize: '2em', + }, + artistDetail: { + padding: 'unset', + paddingBottom: '1rem', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index 0234d416b..ea4a2472a 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -10,6 +10,7 @@ import NordTheme from './nord' import GruvboxDarkTheme from './gruvboxDark' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' import NuclearTheme from './nuclear' +import AmusicTheme from './amusic' export default { // Classic default themes @@ -17,6 +18,7 @@ export default { DarkTheme, // New themes should be added here, in alphabetic order + AmusicTheme, CatppuccinMacchiatoTheme, ElectricPurpleTheme, ExtraDarkTheme, From ee51bd9281c36a50fabafd95f6915cea4c0e5fe9 Mon Sep 17 00:00:00 2001 From: Xavier Araque Date: Sat, 22 Nov 2025 19:41:59 +0100 Subject: [PATCH 27/42] feat(ui): add SquiddiesGlass Theme (#4632) * feat: Add SquiddiesGlass Theme * feat: fix commnets by gemini-code-assist in PR * feat: fix Prettier format * feat: fix play button, and text mobile * feat: fix play button, and text mobile, prettier * feat: fix chip, title artist * fix: loading albbun, play button color * prettier Signed-off-by: Deluan --------- Signed-off-by: Deluan Co-authored-by: Xavier Araque Co-authored-by: Deluan --- ui/src/themes/SquiddiesGlass.css.js | 175 ++++++++ ui/src/themes/SquiddiesGlass.js | 608 ++++++++++++++++++++++++++++ ui/src/themes/index.js | 2 + 3 files changed, 785 insertions(+) create mode 100644 ui/src/themes/SquiddiesGlass.css.js create mode 100644 ui/src/themes/SquiddiesGlass.js diff --git a/ui/src/themes/SquiddiesGlass.css.js b/ui/src/themes/SquiddiesGlass.css.js new file mode 100644 index 000000000..2c8e4f1d6 --- /dev/null +++ b/ui/src/themes/SquiddiesGlass.css.js @@ -0,0 +1,175 @@ +const stylesheet = ` + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + background: #c231ab +} +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track, +.react-jinke-music-player-mobile-progress .rc-slider-track { + background: linear-gradient(to left, #c231ab, #380eff) +} + +.react-jinke-music-player-mobile { + background-color: #171717 !important; +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle { + background: #c231ab; + height: 20px; + width: 20px; + margin-top: -9px; +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #c231ab; +} + +.react-jinke-music-player-pause-icon { + background-color: #c231ab; + border-radius: 50%; + outline: auto; + color: white; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content { + z-index: 99999; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg { + border-radius: 50%; + outline: auto; + color: white; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg:hover { + background-color: #c231ab; + border-radius: 50%; + outline: auto; + color: white; +} + +.react-jinke-music-player-main svg:hover { + color: #c231ab; +} + +.react-jinke-music-player .music-player-controller { + color: #c231ab; + border: 1px solid #e14ac2; +} + +.react-jinke-music-player .music-player-controller.music-player-playing:before { + border: 1px solid rgba(194, 49, 171, 0.3); +} + +.react-jinke-music-player .music-player .destroy-btn { + background-color: #c2c1c2; + top: -7px; + border-radius: 50%; + display: flex; +} + +.react-jinke-music-player .music-player .destroy-btn svg { + font-size: 20px; +} + +@media screen and (max-width: 767px) { + .react-jinke-music-player .music-player .destroy-btn { + right: -12px; + } +} + +.react-jinke-music-player-mobile-header-right { + right: 0; + top: 0; +} + +@media screen and (max-width: 767px) { + .react-jinke-music-player-main svg { + font-size: 32px; + } +} + +@keyframes gradientFlow { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +.RaBulkActionsToolbar .MuiButton-label { + color: white; +} + +a[aria-current="page"] { + color: #c231ab !important; + font-weight: bold; +} + +a[aria-current="page"] .MuiListItemIcon-root { + color: #c231ab !important; +} + +.panel-content { + position: relative; + overflow: hidden; + background: linear-gradient(90deg, #311f2f, #0a0912, #2f0c28); + background-size: 300% 300%; + animation: gradientFlow 10s ease-in-out infinite; +} + +/* Equalizer bars */ +.panel-content::before { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.05) 0px, + rgba(255, 255, 255, 0.05) 2px, + transparent 1px, + transparent 3px + ); + animation: equalizer 1.8s infinite ease-in-out; + filter: blur(1px); + opacity: 0.5; +} + +@keyframes backgroundFlow { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +/* Vertical movement, equalizer type */ +@keyframes equalizer { + 0%, 100% { + transform: scaleY(1); + opacity: 0.2; + } + 25% { + transform: scaleY(1.4); + opacity: 0.9; + } + 50% { + transform: scaleY(0.7); + opacity: 0.2; + } + 75% { + transform: scaleY(1.2); + opacity: 0.8; + } +} + +@keyframes pulse { + 0% { opacity: 0.5; } + 100% { opacity: 1; } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +` + +export default stylesheet diff --git a/ui/src/themes/SquiddiesGlass.js b/ui/src/themes/SquiddiesGlass.js new file mode 100644 index 000000000..5c3844074 --- /dev/null +++ b/ui/src/themes/SquiddiesGlass.js @@ -0,0 +1,608 @@ +import stylesheet from './SquiddiesGlass.css.js' + +/** + * Color constants used throughout the Squiddies Glass theme. + * Provides a consistent color palette with pink, gray, purple, and basic colors. + * @type {Object} + */ +const colors = { + pink: { + 100: '#fbe3f4', + 200: '#f5b9e3', + 300: '#ec7cd6', + 400: '#e14ac2', + 500: '#c231ab', // base + 600: '#a31a92', + 700: '#8b0f7e', + 800: '#7a006d', + 900: '#670066', + }, + gray: { + 50: '#c2c1c2', + 100: '#b3b3b3', // light gray + 200: '#282828', // medium dark + 300: '#1d1d1d', // darker + 400: '#181818', // even darker + 500: '#171717', // darkest + }, + purple: { + 400: '#524590', + 500: '#4d3249', + 600: '#6d1c5e', + }, + black: '#000', + white: '#fff', + dark: '#121212', +} + +/** + * Shared style object for music list action buttons. + * Defines common styling for buttons in music lists, including hover effects and responsive scaling. + * @type {Object} + */ +const musicListActions = { + padding: '1rem 0', + alignItems: 'center', + '@global': { + button: { + border: '1px solid transparent', + backgroundColor: 'inherit', + color: colors.gray[100], + '&:hover': { + border: `1px solid ${colors.gray[100]}`, + backgroundColor: 'inherit !important', + }, + }, + 'button:first-child:not(:only-child)': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.3)', + margin: '1em', + '&:hover': { + transform: 'scale(1.2) !important', + }, + }, + transform: 'scale(1.3)', + margin: '1em', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + background: colors.pink[500], + color: `${colors.black} !important`, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(1.2)', + backgroundColor: `${colors.pink[500]} !important`, + border: 0, + }, + }, + 'button:only-child': { + marginTop: '0.3em', + }, + 'button:first-child>span:first-child': { + padding: 0, + color: `${colors.black} !important`, + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: colors.gray[100], + }, + }, +} + +/** + * Squiddies Glass theme configuration object. + * Defines the complete theme structure including typography, palette, component overrides, and player settings. + * @type {Object} + */ +export default { + /** + * The name of the theme. + * @type {string} + */ + themeName: 'Squiddies Glass', + + /** + * Typography settings for the theme. + * Specifies font family and heading sizes. + * @type {Object} + */ + typography: { + fontFamily: "system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif", + h6: { + fontSize: '1rem', // AppBar title + }, + }, + + /** + * Color palette configuration. + * Defines primary, secondary, and background colors for the theme. + * @type {Object} + */ + palette: { + primary: { + light: colors.pink[300], + main: colors.pink[500], + }, + secondary: { + main: colors.white, + contrastText: colors.white, + }, + background: { + default: colors.dark, + paper: colors.dark, + }, + type: 'dark', + }, + + /** + * Component overrides for Material-UI and custom Navidrome components. + * Customizes the appearance and behavior of various UI components. + * @type {Object} + */ + overrides: { + // Material-UI Components + MuiAppBar: { + positionFixed: { + backgroundColor: `${colors.black} !important`, + boxShadow: 'none', + }, + }, + MuiButton: { + root: { + background: colors.pink[500], + color: colors.white, + border: '1px solid transparent', + borderRadius: 500, + '&:hover': { + background: `${colors.pink[900]} !important`, + }, + }, + textSecondary: { + border: `1px solid ${colors.gray[100]}`, + background: colors.black, + '&:hover': { + border: `1px solid ${colors.white} !important`, + background: `${colors.black} !important`, + }, + }, + label: { + color: colors.white, + paddingRight: '1rem', + paddingLeft: '0.7rem', + }, + }, + MuiCardMedia: { + root: { + position: 'relative', + overflow: 'hidden', + boxShadow: `0 2px 32px rgba(0,0,0,0.5), 0px 1px 5px rgba(0,0,0,0.1)`, + }, + }, + MuiDivider: { + root: { + margin: '.75rem 0', + }, + }, + MuiDrawer: { + root: { + background: colors.gray[500], + paddingTop: '10px', + }, + }, + MuiFormGroup: { + root: { + color: colors.pink[500], + }, + }, + MuiMenuItem: { + root: { + fontSize: '0.875rem', + }, + }, + MuiTableCell: { + root: { + borderBottom: `1px solid ${colors.gray[300]}`, + padding: '10px !important', + color: `${colors.gray[100]} !important`, + '& img': { + filter: + 'brightness(0) saturate(100%) invert(36%) sepia(93%) saturate(7463%) hue-rotate(289deg) brightness(95%) contrast(102%);', + }, + '& img + span': { + color: colors.pink[500], + }, + }, + head: { + borderBottom: `1px solid ${colors.gray[200]}`, + fontSize: '0.75rem', + textTransform: 'uppercase', + letterSpacing: 1.2, + }, + }, + MuiTableRow: { + root: { + padding: '10px 0', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: `${colors.gray[300]} !important`, + }, + '@global': { + 'td:nth-child(4)': { + color: `${colors.white} !important`, + }, + }, + }, + }, + + // React Admin Components + RaBulkActionsToolbar: { + topToolbar: { + gap: '8px', + }, + }, + RaFilter: { + form: { + '& .MuiOutlinedInput-input:-webkit-autofill': { + '-webkit-box-shadow': `0 0 0 100px ${colors.gray[50]} inset`, + '-webkit-text-fill-color': colors.white, + }, + }, + }, + RaFilterButton: { + root: { + marginRight: '1rem', + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: `linear-gradient(${colors.dark}, ${colors.gray[500]})`, + borderTopRightRadius: '8px', + borderTopLeftRadius: '8px', + }, + contentWithSidebar: { + gap: '2px', + }, + }, + RaList: { + content: { + backgroundColor: 'inherit', + }, + bulkActionsDisplayed: { + marginTop: '-20px', + }, + }, + RaListToolbar: { + toolbar: { + padding: '0 .55rem !important', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: `1px solid ${colors.gray[100]}`, + }, + button: { + backgroundColor: 'inherit', + minWidth: 48, + margin: '0 4px', + border: `1px solid ${colors.gray[200]}`, + '@global': { + '> .MuiButton-label': { + padding: 0, + }, + }, + }, + actions: { + '@global': { + '.next-page': { + marginLeft: 8, + marginRight: 8, + }, + '.previous-page': { + marginRight: 8, + }, + }, + }, + }, + RaSearchInput: { + input: { + paddingLeft: '.9rem', + border: 0, + '& .MuiInputBase-root': { + backgroundColor: `${colors.white} !important`, + borderRadius: '20px !important', + color: colors.black, + border: '0px', + '& fieldset': { + borderColor: colors.white, + }, + '&:hover fieldset': { + borderColor: colors.white, + }, + '&.Mui-focused fieldset': { + borderColor: colors.white, + }, + '& svg': { + color: `${colors.black} !important`, + }, + '& .MuiOutlinedInput-input:-webkit-autofill': { + borderRadius: '20px 0px 0px 20px', + '-webkit-box-shadow': `0 0 0 100px ${colors.gray[50]} inset`, + '-webkit-text-fill-color': colors.black, + }, + }, + }, + }, + RaSidebar: { + root: { + height: 'initial', + borderTopRightRadius: '8px', + borderTopLeftRadius: '8px', + }, + }, + + // Navidrome Custom Components + NDAlbumDetails: { + root: { + boxShadow: 'none', + background: `linear-gradient(45deg, ${colors.purple[500]}, ${colors.purple[400]}, ${colors.purple[600]})`, + backgroundSize: '200% 200%', + animation: 'gradientFlow 8s ease-in-out infinite', + position: 'relative', + '&:before': { + content: '""', + position: 'absolute', + top: '0', + left: '0', + width: '100%', + height: '100%', + background: `linear-gradient(to bottom, transparent, ${colors.dark})`, + }, + }, + cardContents: { + alignItems: 'flex-start', + }, + coverParent: { + zIndex: '99999', + position: 'relative', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + '&::before': { + content: '""', + position: 'absolute', + inset: '0', + width: '100%', + height: '100%', + borderRadius: '50%', + animation: 'pulse 1.5s ease-in-out infinite alternate', + zIndex: -1, + }, + '&::after': { + content: '""', + position: 'absolute', + inset: '0', + zIndex: '-1', + borderRadius: '50%', + background: + 'repeating-conic-gradient(from 0deg, rgba(255,255,255,0.08) 0deg, rgba(255,255,255,0.08) 0.5deg, rgba(0,0,0,1) 1deg)', + filter: 'contrast(999) sepia(1)', + boxShadow: + 'inset 0 0 25px rgba(255,255,255,0.05), inset 0 0 95px rgba(0,0,0,0.9)', + animation: 'spin 6s linear infinite', + }, + }, + details: { + zIndex: '99999', + }, + recordName: { + fontSize: 'calc(1rem + 1.5vw)', + fontWeight: 900, + }, + recordArtist: { + fontSize: '1.5rem', + fontWeight: 700, + textShadow: '0 2px 16px rgba(0, 0, 0, 0.3)', + }, + recordMeta: { + fontSize: '.875rem', + color: `rgba(${colors.white}, 0.8)`, + }, + content: { + paddingBottom: '0px !important', + paddingTop: '0px', + }, + }, + RaSingleFieldList: { + root: { + '& a:first-of-type > .MuiChip-root': { + marginLeft: '0px', + }, + '& a > .MuiChip-root': { + backgroundColor: colors.pink[500], + fontSize: '0.6rem', + height: '20px', + '& .MuiChip-label': { + color: colors.white, + paddingLeft: '5px', + paddingRight: '5px', + }, + }, + }, + }, + MuiGridListTile: { + tile: { + '&:hover': { + boxShadow: '0 2px 32px rgba(0,0,0,0.5), 0px 1px 5px rgba(0,0,0,0.1)', + }, + }, + }, + NDAlbumGridView: { + tileBar: { + background: + 'linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.4) 50%, rgba(0, 0, 0, 0) 100%)', + marginBottom: '2px', + }, + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + textTransform: 'none', + color: colors.white, + }, + albumSubtitle: { + color: colors.gray[100], + }, + albumContainer: { + backgroundColor: colors.gray[400], + borderRadius: '.5rem', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: colors.gray[200], + }, + }, + albumPlayButton: { + color: colors.black, + backgroundColor: colors.pink[500], + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${colors.pink[500]} !important`, + padding: '0.45rem', + }, + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDArtistShow: { + actions: { + padding: '2rem 0', + alignItems: 'center', + overflow: 'visible', + minHeight: '120px', + '@global': { + button: { + border: '1px solid transparent', + backgroundColor: 'inherit', + color: colors.gray[100], + margin: '0 0.5rem', + '&:hover': { + border: `1px solid ${colors.gray[100]}`, + backgroundColor: 'inherit !important', + }, + }, + // Hide shuffle button label (first button) + 'button:first-child>span:first-child>span': { + display: 'none', + }, + // Style shuffle button (first button) + 'button:first-child': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + background: colors.pink[500], + color: colors.white, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${colors.pink[500]} !important`, + border: 0, + }, + }, + 'button:first-child>span:first-child': { + padding: 0, + color: `${colors.black} !important`, + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: colors.gray[100], + }, + }, + }, + actionsContainer: { + overflow: 'visible', + }, + }, + NDAudioPlayer: { + audioTitle: { + color: colors.white, + fontSize: '1.5rem', + '& span:nth-child(3)': { + fontSize: '0.8rem', + }, + }, + songTitle: { + fontWeight: 900, + }, + songInfo: { + fontSize: '0.9rem', + color: colors.gray[100], + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: `rgba(${colors.white}, 0.8)`, + }, + }, + NDLogin: { + main: { + boxShadow: `inset 0 0 0 2000px rgba(${colors.black}, .75)`, + }, + systemNameLink: { + color: colors.white, + }, + card: { + border: `1px solid ${colors.gray[200]}`, + }, + avatar: { + marginBottom: 0, + }, + }, + NDPlaylistDetails: { + container: { + background: `linear-gradient(${colors.gray[300]}, transparent)`, + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontSize: 'calc(1.5rem + 1.5vw)', + fontWeight: 700, + color: colors.white, + }, + details: { + fontSize: '.875rem', + color: `rgba(${colors.white}, 0.8)`, + }, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + }, + + /** + * Player configuration settings. + * Specifies the player theme and associated stylesheet. + * @type {Object} + */ + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index ea4a2472a..5f9060383 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -11,6 +11,7 @@ import GruvboxDarkTheme from './gruvboxDark' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' import NuclearTheme from './nuclear' import AmusicTheme from './amusic' +import SquiddiesGlassTheme from './SquiddiesGlass' export default { // Classic default themes @@ -29,4 +30,5 @@ export default { NordTheme, NuclearTheme, SpotifyTheme, + SquiddiesGlassTheme, } From c21aee736006d20def0bc018bc90e901cf9e9797 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 22 Nov 2025 20:14:44 -0500 Subject: [PATCH 28/42] fix(config): enables quoted `;` as values in ini files Signed-off-by: Deluan --- conf/configuration.go | 7 ++++++- conf/configuration_test.go | 1 + conf/testdata/cfg.ini | 5 +++-- conf/testdata/cfg.json | 3 +++ conf/testdata/cfg.toml | 2 ++ conf/testdata/cfg.yaml | 2 ++ 6 files changed, 17 insertions(+), 3 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 0ad81492a..8be005591 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -617,7 +617,12 @@ func init() { func InitConfig(cfgFile string) { codecRegistry := viper.NewCodecRegistry() - _ = codecRegistry.RegisterCodec("ini", ini.Codec{}) + _ = codecRegistry.RegisterCodec("ini", ini.Codec{ + LoadOptions: ini.LoadOptions{ + UnescapeValueDoubleQuotes: true, + UnescapeValueCommentSymbols: true, + }, + }) viper.SetOptions(viper.WithCodecRegistry(codecRegistry)) cfgFile = getConfigFile(cfgFile) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 5b54e4975..88454d204 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -39,6 +39,7 @@ var _ = Describe("Configuration", func() { Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format))) Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format)) Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"})) + Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"})) // The config file used should be the one we created Expect(conf.Server.ConfigFile).To(Equal(filename)) diff --git a/conf/testdata/cfg.ini b/conf/testdata/cfg.ini index cec7d3c70..e0062ff0e 100644 --- a/conf/testdata/cfg.ini +++ b/conf/testdata/cfg.ini @@ -1,6 +1,7 @@ [default] MusicFolder = /ini/music -UIWelcomeMessage = Welcome ini +UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions [Tags] -Custom.Aliases = ini,test \ No newline at end of file +Custom.Aliases = ini,test +artist.Split = ";" # Should be able to read ; as a separator \ No newline at end of file diff --git a/conf/testdata/cfg.json b/conf/testdata/cfg.json index 37cf74f08..127103a53 100644 --- a/conf/testdata/cfg.json +++ b/conf/testdata/cfg.json @@ -2,6 +2,9 @@ "musicFolder": "/json/music", "uiWelcomeMessage": "Welcome json", "Tags": { + "artist": { + "split": ";" + }, "custom": { "aliases": [ "json", diff --git a/conf/testdata/cfg.toml b/conf/testdata/cfg.toml index 1dc852b18..d94d786e2 100644 --- a/conf/testdata/cfg.toml +++ b/conf/testdata/cfg.toml @@ -1,5 +1,7 @@ musicFolder = "/toml/music" uiWelcomeMessage = "Welcome toml" +Tags.artist.Split = ';' + [Tags.custom] aliases = ["toml", "test"] diff --git a/conf/testdata/cfg.yaml b/conf/testdata/cfg.yaml index 38b98d4aa..66e12c4eb 100644 --- a/conf/testdata/cfg.yaml +++ b/conf/testdata/cfg.yaml @@ -1,6 +1,8 @@ musicFolder: "/yaml/music" uiWelcomeMessage: "Welcome yaml" Tags: + artist: + split: [";"] custom: aliases: - yaml From 12d08985855353681c02d9eb9448cd69dc5f69bf Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 22 Nov 2025 21:36:44 -0500 Subject: [PATCH 29/42] chore(docker): remove GODEBUG=asyncpreemptoff=1 flag, as it should not be needed on Go 1.15+ Signed-off-by: Deluan --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index fb1cf997b..6568ce9d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -137,7 +137,6 @@ ENV ND_MUSICFOLDER=/music ENV ND_DATAFOLDER=/data ENV ND_CONFIGFILE=/data/navidrome.toml ENV ND_PORT=4533 -ENV GODEBUG="asyncpreemptoff=1" RUN touch /.nddockerenv EXPOSE ${ND_PORT} From c40f12e65bc4390543098e3c4204c6e9d7656b90 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 23 Nov 2025 19:16:10 -0800 Subject: [PATCH 30/42] fix(scanner): Use repeated arg instead of comma split (#4727) --- cmd/scan.go | 9 ++++----- scanner/external.go | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/cmd/scan.go b/cmd/scan.go index 41d281070..e587b8931 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -4,7 +4,6 @@ import ( "context" "encoding/gob" "os" - "strings" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/db" @@ -19,13 +18,13 @@ import ( var ( fullScan bool subprocess bool - targets string + targets []string ) func init() { scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps") scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)") - scanCmd.Flags().StringVarP(&targets, "targets", "t", "", "comma-separated list of libraryID:folderPath pairs (e.g., \"1:Music/Rock,1:Music/Jazz,2:Classical\")") + scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")") rootCmd.AddCommand(scanCmd) } @@ -74,9 +73,9 @@ func runScanner(ctx context.Context) { // Parse targets if provided var scanTargets []model.ScanTarget - if targets != "" { + if len(targets) > 0 { var err error - scanTargets, err = model.ParseTargets(strings.Split(targets, ",")) + scanTargets, err = model.ParseTargets(targets) if err != nil { log.Fatal(ctx, "Failed to parse targets", err) } diff --git a/scanner/external.go b/scanner/external.go index b6d7639be..f5a117e48 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -8,12 +8,10 @@ import ( "io" "os" "os/exec" - "strings" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" ) // scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid @@ -47,9 +45,10 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod // Add targets if provided if len(targets) > 0 { - targetsStr := strings.Join(slice.Map(targets, func(t model.ScanTarget) string { return t.String() }), ",") - args = append(args, "--targets", targetsStr) - log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targetsStr) + for _, target := range targets { + args = append(args, "-t", target.String()) + } + log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targets) } else { log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe) } From a6a682b385a973bf75edf174cc0eab018596796c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:18:34 -0500 Subject: [PATCH 31/42] chore(deps): bump actions/checkout from 5 to 6 in /.github/workflows (#4730) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pipeline.yml | 18 +++++++++--------- .github/workflows/update-translations.yml | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 0767346fa..851e04c8e 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -25,7 +25,7 @@ jobs: git_tag: ${{ steps.git-version.outputs.GIT_TAG }} git_sha: ${{ steps.git-version.outputs.GIT_SHA }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true @@ -63,7 +63,7 @@ jobs: name: Lint Go code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Download TagLib uses: ./.github/actions/download-taglib @@ -93,7 +93,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Download TagLib uses: ./.github/actions/download-taglib @@ -114,7 +114,7 @@ jobs: env: NODE_OPTIONS: "--max_old_space_size=4096" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 24 @@ -145,7 +145,7 @@ jobs: name: Lint i18n files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: | set -e for file in resources/i18n/*.json; do @@ -191,7 +191,7 @@ jobs: PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Prepare Docker Buildx uses: ./.github/actions/prepare-docker @@ -264,7 +264,7 @@ jobs: env: REGISTRY_IMAGE: ghcr.io/${{ github.repository }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Download digests uses: actions/download-artifact@v6 @@ -318,7 +318,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/download-artifact@v6 with: @@ -352,7 +352,7 @@ jobs: outputs: package_list: ${{ steps.set-package-list.outputs.package_list }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 69ca1cc94..cc120cb8d 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository_owner == 'navidrome' }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Get updated translations id: poeditor env: From 228211f925503e7d477a23443f5376367e61500b Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 24 Nov 2025 21:16:28 -0500 Subject: [PATCH 32/42] test: add smart playlist tag criteria tests for issue #4728 Add integration tests verifying the workaround for checking if a tag has any value in smart playlists. The tests confirm that using 'contains' with an empty string generates SQL that matches any non-empty tag value (value LIKE '%%'), which is the recommended workaround for issue #4728. Tests added: - Verify contains with empty string matches tracks with tag values - Verify notContains with empty string excludes tracks with tag values Also updated test context to use GinkgoT().Context() instead of context.TODO(). --- persistence/playlist_repository_test.go | 118 +++++++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 7fad93b1e..5f9af2a33 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,7 +1,6 @@ package persistence import ( - "context" "time" "github.com/navidrome/navidrome/conf" @@ -11,13 +10,14 @@ import ( "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { var repo model.PlaylistRepository BeforeEach(func() { - ctx := log.NewContext(context.TODO()) + ctx := log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) @@ -252,4 +252,118 @@ var _ = Describe("PlaylistRepository", func() { Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 }) }) + + Describe("Smart Playlists with Tag Criteria", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var songWithGrouping, songWithoutGrouping model.MediaFile + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + + // Register 'grouping' as a valid tag for smart playlists + criteria.AddTagNames([]string{"grouping"}) + + // Create a song with the grouping tag + songWithGrouping = model.MediaFile{ + ID: "test-grouping-1", + Title: "Song With Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "/test/grouping/song1.mp3", + Tags: model.Tags{ + "grouping": []string{"My Crate"}, + }, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) + + // Create a song without the grouping tag + songWithoutGrouping = model.MediaFile{ + ID: "test-grouping-2", + Title: "Song Without Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "/test/grouping/song2.mp3", + Tags: model.Tags{}, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) + }) + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test media files + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute() + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute() + }) + + It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() { + By("creating a smart playlist that checks if grouping tag has any value") + // This is the workaround for issue #4728: using 'contains' with empty string + // generates SQL: value LIKE '%%' which matches any non-empty string + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track with grouping tag is matched") + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID)) + }) + + It("excludes tracks with a tag value using 'notContains' with empty string", func() { + By("creating a smart playlist that checks if grouping tag is NOT set") + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.NotContains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying the track with grouping is NOT in the playlist") + for _, track := range pls.Tracks { + Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID)) + } + + By("verifying the track without grouping IS in the playlist") + var foundWithoutGrouping bool + for _, track := range pls.Tracks { + if track.MediaFileID == songWithoutGrouping.ID { + foundWithoutGrouping = true + break + } + } + Expect(foundWithoutGrouping).To(BeTrue()) + }) + }) }) From 3294bcacfc089fdbfae3de65a6a69ae8fcfe6daa Mon Sep 17 00:00:00 2001 From: zacaj Date: Mon, 24 Nov 2025 23:18:05 -0500 Subject: [PATCH 33/42] feat: add Rated At field - #4653 (#4660) * feat(model): add Rated At field - #4653 Signed-off-by: zacaj * fix(ui): ignore empty dates in rating/love tooltips - #4653 * refactor(ui): add isDateSet util function Signed-off-by: zacaj * feat: add tests for isDateSet and rated_at sort mappings Added comprehensive tests for isDateSet and urlValidate functions in ui/src/utils/validations.test.js covering falsy values, Go zero date handling, valid date strings, Date objects, and edge cases. Added rated_at sort mapping to album, artist, and mediafile repositories, following the same pattern as starred_at (sorting by rating first, then by timestamp). This enables proper sorting by rating date in the UI. --------- Signed-off-by: zacaj Co-authored-by: zacaj Co-authored-by: Deluan --- ...51109010105_add_annotation_rating_date.sql | 7 ++ model/annotation.go | 1 + model/criteria/fields.go | 1 + persistence/album_repository.go | 1 + persistence/artist_repository.go | 1 + persistence/mediafile_repository.go | 1 + persistence/playlist_repository.go | 1 + persistence/playlist_track_repository.go | 1 + persistence/sql_annotations.go | 4 +- ui/src/common/DateField.jsx | 3 +- ui/src/common/LoveButton.jsx | 8 +- ui/src/common/RatingField.jsx | 10 ++- ui/src/utils/validations.js | 13 ++++ ui/src/utils/validations.test.js | 73 +++++++++++++++++++ 14 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 db/migrations/20251109010105_add_annotation_rating_date.sql create mode 100644 ui/src/utils/validations.test.js diff --git a/db/migrations/20251109010105_add_annotation_rating_date.sql b/db/migrations/20251109010105_add_annotation_rating_date.sql new file mode 100644 index 000000000..9dac46a5e --- /dev/null +++ b/db/migrations/20251109010105_add_annotation_rating_date.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE annotation ADD COLUMN rated_at datetime; +-- +goose StatementEnd + +-- +goose Down + \ No newline at end of file diff --git a/model/annotation.go b/model/annotation.go index 2ec72c1b7..fbff5f178 100644 --- a/model/annotation.go +++ b/model/annotation.go @@ -6,6 +6,7 @@ type Annotations struct { PlayCount int64 `structs:"play_count" json:"playCount,omitempty"` PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" ` Rating int `structs:"rating" json:"rating,omitempty" ` + RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" ` Starred bool `structs:"starred" json:"starred,omitempty" ` StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"` } diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 70719cd6f..5381ae597 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -44,6 +44,7 @@ var fieldMap = map[string]*mappedField{ "loved": {field: "COALESCE(annotation.starred, false)"}, "dateloved": {field: "annotation.starred_at"}, "lastplayed": {field: "annotation.play_date"}, + "daterated": {field: "annotation.rated_at"}, "playcount": {field: "COALESCE(annotation.play_count, 0)"}, "rating": {field: "COALESCE(annotation.rating, 0)"}, "mbz_album_id": {field: "media_file.mbz_album_id"}, diff --git a/persistence/album_repository.go b/persistence/album_repository.go index b1ce23e2b..dab255784 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -106,6 +106,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito "random": "random", "recently_added": recentlyAddedSort(), "starred_at": "starred, starred_at", + "rated_at": "rating, rated_at", }) return r } diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 6d08c27db..c9e38a1ee 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -141,6 +141,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi r.setSortMappings(map[string]string{ "name": "order_artist_name", "starred_at": "starred, starred_at", + "rated_at": "rating, rated_at", "song_count": "stats->>'total'->>'m'", "album_count": "stats->>'total'->>'a'", "size": "stats->>'total'->>'s'", diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index e7883947a..8f32accc6 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -84,6 +84,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile "created_at": "media_file.created_at", "recently_added": mediaFileRecentlyAddedSort(), "starred_at": "starred, starred_at", + "rated_at": "rating, rated_at", }) return r } diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 046284e1f..a94f95a78 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -388,6 +388,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla "coalesce(play_count, 0) as play_count", "play_date", "coalesce(rating, 0) as rating", + "rated_at", "f.*", "playlist_tracks.*", "library.path as library_path", diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index b3f9e0c07..666f227e2 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -97,6 +97,7 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) { "coalesce(rating, 0) as rating", "starred_at", "play_date", + "rated_at", "f.*", "playlist_tracks.*", ). diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 98ade6e21..108e9be94 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -28,6 +28,7 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec "coalesce(rating, 0) as rating", "starred_at", "play_date", + "rated_at", ) if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" { query = query.Columns( @@ -77,7 +78,8 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error { } func (r sqlRepository) SetRating(rating int, itemID string) error { - return r.annUpsert(map[string]interface{}{"rating": rating}, itemID) + ratedAt := time.Now() + return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID) } func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx index fab15b53c..dce24a2b9 100644 --- a/ui/src/common/DateField.jsx +++ b/ui/src/common/DateField.jsx @@ -1,10 +1,11 @@ import React from 'react' +import { isDateSet } from '../utils/validations' import { DateField as RADateField } from 'react-admin' export const DateField = (props) => { const { record, source } = props const value = record?.[source] - if (value === '0001-01-01T00:00:00Z' || value === null) return null + if (!isDateSet(value)) return null return } diff --git a/ui/src/common/LoveButton.jsx b/ui/src/common/LoveButton.jsx index f42d92ff4..c940acf12 100644 --- a/ui/src/common/LoveButton.jsx +++ b/ui/src/common/LoveButton.jsx @@ -7,6 +7,7 @@ import { makeStyles } from '@material-ui/core/styles' import { useToggleLove } from './useToggleLove' import { useRecordContext } from 'react-admin' import config from '../config' +import { isDateSet } from '../utils/validations' const useStyles = makeStyles({ love: { @@ -46,8 +47,13 @@ export const LoveButton = ({