diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 077e33d1..35bda877 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "iter" "maps" "slices" "strings" @@ -302,17 +303,21 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) if err != nil { return nil, err } + return wrapAlbumCursor(cursor), nil +} + +func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor { return func(yield func(model.Album, error) bool) { for a, err := range cursor { if a.Album == nil { - yield(model.Album{}, fmt.Errorf("unexpected nil album: %v", a)) + yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err)) return } if !yield(*a.Album, err) || err != nil { return } } - }, nil + } } // RefreshPlayCounts updates the play count and last play date annotations for all albums, based diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 9fbc6b97..66b6eba9 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -1,6 +1,7 @@ package persistence import ( + "errors" "fmt" "time" @@ -743,6 +744,46 @@ var _ = Describe("AlbumRepository", func() { _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) }) + + Describe("wrapAlbumCursor", func() { + It("does not panic when the cursor yields a dbAlbum with nil Album", func() { + // Simulate what queryWithStableResults does on the rows.Err() path: + // it yields a zero-value dbAlbum (where Album is nil) with an error. + dbErr := fmt.Errorf("database is locked") + cursor := func(yield func(dbAlbum, error) bool) { + var empty dbAlbum // Album pointer is nil + yield(empty, dbErr) + } + + // wrapAlbumCursor should handle the nil Album without panicking + wrappedCursor := wrapAlbumCursor(cursor) + var gotErr error + Expect(func() { + for _, err := range wrappedCursor { + gotErr = err + } + }).ToNot(Panic()) + Expect(gotErr).To(HaveOccurred()) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album")) + Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") + }) + + It("yields albums from a valid cursor", func() { + album := &model.Album{ID: "a1", Name: "Test"} + cursor := func(yield func(dbAlbum, error) bool) { + yield(dbAlbum{Album: album}, nil) + } + + wrappedCursor := wrapAlbumCursor(cursor) + var albums []model.Album + for a, err := range wrappedCursor { + Expect(err).ToNot(HaveOccurred()) + albums = append(albums, a) + } + Expect(albums).To(HaveLen(1)) + Expect(albums[0].ID).To(Equal("a1")) + }) + }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index a4e20346..f7bb6a4f 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "iter" "maps" "os" "path/filepath" @@ -218,13 +219,21 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) if err != nil { return nil, err } + return wrapFolderCursor(cursor), nil +} + +func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { return func(yield func(model.Folder, error) bool) { for f, err := range cursor { + if f.Folder == nil { + yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err)) + return + } if !yield(*f.Folder, err) || err != nil { return } } - }, nil + } } func (r folderRepository) purgeEmpty(libraryIDs ...int) error { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 6c24741c..7b6a0f76 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "errors" "fmt" "github.com/navidrome/navidrome/log" @@ -210,4 +211,44 @@ var _ = Describe("FolderRepository", func() { }) }) }) + + Describe("wrapFolderCursor", func() { + It("does not panic when the cursor yields a dbFolder with nil Folder", func() { + // Simulate what queryWithStableResults does on the rows.Err() path: + // it yields a zero-value dbFolder (where Folder is nil) with an error. + dbErr := fmt.Errorf("database is locked") + cursor := func(yield func(dbFolder, error) bool) { + var empty dbFolder // Folder pointer is nil + yield(empty, dbErr) + } + + // wrapFolderCursor should handle the nil Folder without panicking + wrappedCursor := wrapFolderCursor(cursor) + var gotErr error + Expect(func() { + for _, err := range wrappedCursor { + gotErr = err + } + }).ToNot(Panic()) + Expect(gotErr).To(HaveOccurred()) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder")) + Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") + }) + + It("yields folders from a valid cursor", func() { + folder := &model.Folder{ID: "f1", Name: "Test"} + cursor := func(yield func(dbFolder, error) bool) { + yield(dbFolder{Folder: folder}, nil) + } + + wrappedCursor := wrapFolderCursor(cursor) + var folders []model.Folder + for f, err := range wrappedCursor { + Expect(err).ToNot(HaveOccurred()) + folders = append(folders, f) + } + Expect(folders).To(HaveLen(1)) + Expect(folders[0].ID).To(Equal("f1")) + }) + }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 9034fa8f..43736e31 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -3,6 +3,7 @@ package persistence import ( "context" "fmt" + "iter" "slices" "strconv" "strings" @@ -231,17 +232,7 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me if err != nil { return nil, err } - return func(yield func(model.MediaFile, error) bool) { - for m, err := range cursor { - if m.MediaFile == nil { - yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile: %v", m)) - return - } - if !yield(*m.MediaFile, err) || err != nil { - return - } - } - }, nil + return wrapMediaFileCursor(cursor), nil } // FindByPaths finds media files by their paths. @@ -371,13 +362,21 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC if err != nil { return nil, err } + return wrapMediaFileCursor(cursor), nil +} + +func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor { return func(yield func(model.MediaFile, error) bool) { for m, err := range cursor { + if m.MediaFile == nil { + yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err)) + return + } if !yield(*m.MediaFile, err) || err != nil { return } } - }, nil + } } // FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 7a04d79e..5a866379 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -2,6 +2,8 @@ package persistence import ( "context" + "errors" + "fmt" "time" "github.com/Masterminds/squirrel" @@ -711,4 +713,44 @@ var _ = Describe("MediaRepository", func() { Expect(results).To(BeEmpty()) }) }) + + Describe("wrapMediaFileCursor", func() { + It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() { + // Simulate what queryWithStableResults does on the rows.Err() path: + // it yields a zero-value dbMediaFile (where MediaFile is nil) with an error. + dbErr := fmt.Errorf("database is locked") + cursor := func(yield func(dbMediaFile, error) bool) { + var empty dbMediaFile // MediaFile pointer is nil + yield(empty, dbErr) + } + + // wrapMediaFileCursor should handle the nil MediaFile without panicking + wrappedCursor := wrapMediaFileCursor(cursor) + var gotErr error + Expect(func() { + for _, err := range wrappedCursor { + gotErr = err + } + }).ToNot(Panic()) + Expect(gotErr).To(HaveOccurred()) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile")) + Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") + }) + + It("yields mediafiles from a valid cursor", func() { + mf := &model.MediaFile{ID: "mf1", Title: "Test"} + cursor := func(yield func(dbMediaFile, error) bool) { + yield(dbMediaFile{MediaFile: mf}, nil) + } + + wrappedCursor := wrapMediaFileCursor(cursor) + var mediafiles []model.MediaFile + for m, err := range wrappedCursor { + Expect(err).ToNot(HaveOccurred()) + mediafiles = append(mediafiles, m) + } + Expect(mediafiles).To(HaveLen(1)) + Expect(mediafiles[0].ID).To(Equal("mf1")) + }) + }) })