🏡 index : quine-global/quintodrome.git

package scanner

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"sort"

	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/core/playlists"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"github.com/stretchr/testify/mock"
)

var _ = Describe("phasePlaylists", func() {
	var (
		phase      *phasePlaylists
		ctx        context.Context
		state      *scanState
		folderRepo *mockFolderRepository
		ds         *tests.MockDataStore
		pls        *mockPlaylists
	)

	var userRepo *tests.MockedUserRepo
	var propRepo *tests.MockedPropertyRepo

	BeforeEach(func() {
		DeferCleanup(configtest.SetupConfig())
		conf.Server.AutoImportPlaylists = true
		ctx = context.Background()
		folderRepo = &mockFolderRepository{}
		userRepo = tests.CreateMockUserRepo()
		// An admin user exists by default, so playlist import proceeds.
		Expect(userRepo.Put(ctx, &model.User{ID: "123", UserName: "admin", IsAdmin: true})).To(Succeed())
		propRepo = &tests.MockedPropertyRepo{}
		ds = &tests.MockDataStore{
			MockedFolder:   folderRepo,
			MockedUser:     userRepo,
			MockedProperty: propRepo,
		}
		pls = &mockPlaylists{}
		state = &scanState{}
		phase = createPhasePlaylists(ctx, state, ds, pls)
	})

	Describe("description", func() {
		It("returns the correct description", func() {
			Expect(phase.description()).To(Equal("Import/update playlists"))
		})
	})

	Describe("producer", func() {
		It("produces folders with playlists", func() {
			folderRepo.SetData(map[*model.Folder]error{
				{Path: "/path/to/folder1"}: nil,
				{Path: "/path/to/folder2"}: nil,
			})

			var produced []*model.Folder
			err := phase.produce(func(folder *model.Folder) {
				produced = append(produced, folder)
			})

			sort.Slice(produced, func(i, j int) bool {
				return produced[i].Path < produced[j].Path
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(produced).To(HaveLen(2))
			Expect(produced[0].Path).To(Equal("/path/to/folder1"))
			Expect(produced[1].Path).To(Equal("/path/to/folder2"))
		})

		It("returns an error if there is an error loading folders", func() {
			folderRepo.SetData(map[*model.Folder]error{
				nil: errors.New("error loading folders"),
			})

			called := false
			err := phase.produce(func(folder *model.Folder) { called = true })

			Expect(err).To(HaveOccurred())
			Expect(called).To(BeFalse())
			Expect(err).To(MatchError(ContainSubstring("error loading folders")))
		})

		It("sets the pending flag and imports nothing when no admin user exists", func() {
			// Remove the admin user; produce resolves the admin at phase time.
			userRepo.Data = map[string]*model.User{}
			folderRepo.SetData(map[*model.Folder]error{
				{Path: "/path/to/folder1"}: nil,
			})

			called := false
			err := phase.produce(func(folder *model.Folder) { called = true })

			Expect(err).ToNot(HaveOccurred())
			Expect(called).To(BeFalse())
			v, _ := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey)
			Expect(v).To(Equal("1"))
		})

		It("returns an error (not a silent defer) on a datastore failure resolving the admin", func() {
			userRepo.Error = errors.New("db is locked")

			err := phase.produce(func(folder *model.Folder) {})

			Expect(err).To(MatchError(ContainSubstring("finding admin user")))
			// Must NOT have set the pending flag on a real error.
			_, getErr := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey)
			Expect(getErr).To(HaveOccurred())
		})

		It("returns an error when the pending flag cannot be persisted", func() {
			userRepo.Data = map[string]*model.User{} // no admin -> defer path
			propRepo.Error = errors.New("property table unavailable")

			err := phase.produce(func(folder *model.Folder) {})

			Expect(err).To(MatchError(ContainSubstring("recording pending playlist import")))
		})

		It("imports all playlist folders when the pending flag is set", func() {
			Expect(propRepo.Put(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed())
			folderRepo.SetAllData(map[*model.Folder]error{
				{Path: "/path/to/folder1"}: nil,
				{Path: "/path/to/folder2"}: nil,
			})
			// Touched set is empty: proves selection used GetAllWithPlaylists.
			folderRepo.SetData(map[*model.Folder]error{})

			var produced []*model.Folder
			err := phase.produce(func(folder *model.Folder) { produced = append(produced, folder) })

			Expect(err).ToNot(HaveOccurred())
			Expect(produced).To(HaveLen(2))
			Expect(phase.pendingImport).To(BeTrue())
		})
	})

	Describe("finalize", func() {
		It("clears the pending flag after a successful pending import", func() {
			Expect(propRepo.Put(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed())
			phase.pendingImport = true

			Expect(phase.finalize(nil)).To(Succeed())

			_, err := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey)
			Expect(err).To(HaveOccurred()) // deleted
		})

		It("keeps the pending flag when the import failed", func() {
			Expect(propRepo.Put(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed())
			phase.pendingImport = true

			Expect(phase.finalize(errors.New("boom"))).To(HaveOccurred())

			v, _ := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey)
			Expect(v).To(Equal("1"))
		})
	})

	Describe("processPlaylistsInFolder", func() {
		It("processes playlists in a folder", func() {
			libPath := GinkgoT().TempDir()
			folder := &model.Folder{LibraryPath: libPath, Path: "path/to", Name: "folder"}
			_ = os.MkdirAll(folder.AbsolutePath(), 0755)

			file1 := filepath.Join(folder.AbsolutePath(), "playlist1.m3u")
			file2 := filepath.Join(folder.AbsolutePath(), "playlist2.m3u")
			_ = os.WriteFile(file1, []byte{}, 0600)
			_ = os.WriteFile(file2, []byte{}, 0600)

			pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u").
				Return(&model.Playlist{}, nil)
			pls.On("ImportFromFolder", mock.Anything, folder, "playlist2.m3u").
				Return(&model.Playlist{}, nil)

			_, err := phase.processPlaylistsInFolder(folder)
			Expect(err).ToNot(HaveOccurred())
			Expect(pls.Calls).To(HaveLen(2))
			Expect(pls.Calls[0].Arguments[2]).To(Equal("playlist1.m3u"))
			Expect(pls.Calls[1].Arguments[2]).To(Equal("playlist2.m3u"))
			Expect(phase.refreshed.Load()).To(Equal(uint32(2)))
		})

		It("enqueues artwork resolution for the imported playlist", func() {
			libPath := GinkgoT().TempDir()
			folder := &model.Folder{LibraryPath: libPath, Path: "path/to", Name: "folder"}
			_ = os.MkdirAll(folder.AbsolutePath(), 0755)

			file1 := filepath.Join(folder.AbsolutePath(), "playlist1.m3u")
			_ = os.WriteFile(file1, []byte{}, 0600)

			pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u").
				Return(&model.Playlist{ID: "pl1"}, nil)

			_, err := phase.processPlaylistsInFolder(folder)
			Expect(err).ToNot(HaveOccurred())

			queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 10)
			Expect(err).ToNot(HaveOccurred())
			Expect(queued).To(ContainElement(SatisfyAll(
				HaveField("ItemKind", "pl"),
				HaveField("ItemID", "pl1"),
				HaveField("Priority", model.ArtworkPriorityScan),
			)))
		})

		It("reports an error if there is an error reading files", func() {
			tests.SkipOnWindows("relies on Unix /etc filesystem")
			progress := make(chan *ProgressInfo)
			state.progress = progress
			folder := &model.Folder{Path: "/invalid/path"}
			go func() {
				_, err := phase.processPlaylistsInFolder(folder)
				// I/O errors are ignored
				Expect(err).ToNot(HaveOccurred())
			}()

			// But are reported
			info := &ProgressInfo{}
			Eventually(progress).Should(Receive(&info))
			Expect(info.Warning).To(ContainSubstring("no such file or directory"))
		})
	})
})

type mockPlaylists struct {
	mock.Mock
	playlists.Playlists
}

func (p *mockPlaylists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
	args := p.Called(ctx, folder, filename)
	return args.Get(0).(*model.Playlist), args.Error(1)
}

type mockFolderRepository struct {
	model.FolderRepository
	data    map[*model.Folder]error
	allData map[*model.Folder]error
}

func cursorFromData(data map[*model.Folder]error) model.FolderCursor {
	return func(yield func(model.Folder, error) bool) {
		for folder, err := range data {
			if err != nil {
				if !yield(model.Folder{}, err) {
					return
				}
				continue
			}
			if !yield(*folder, err) {
				return
			}
		}
	}
}

func (f *mockFolderRepository) GetTouchedWithPlaylists(context.Context) (model.FolderCursor, error) {
	return cursorFromData(f.data), nil
}

func (f *mockFolderRepository) GetAllWithPlaylists(context.Context) (model.FolderCursor, error) {
	return cursorFromData(f.allData), nil
}

func (f *mockFolderRepository) SetData(m map[*model.Folder]error) {
	f.data = m
}

func (f *mockFolderRepository) SetAllData(m map[*model.Folder]error) {
	f.allData = m
}