🏡 index : quine-global/quintodrome.git

package persistence

import (
	"context"
	"time"

	"github.com/Masterminds/squirrel"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	"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("LibraryRepository", func() {
	var repo model.LibraryRepository
	var ctx context.Context
	var conn *dbx.DB

	BeforeEach(func() {
		ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"})
		conn = GetDBXBuilder()
		repo = NewLibraryRepository(conn)
	})

	AfterEach(func() {
		// Clean up test libraries (keep ID 1 which is the default library)
		_, _ = conn.NewQuery("DELETE FROM library WHERE id > 1").Execute()
	})

	Describe("Put", func() {
		Context("when ID is 0", func() {
			It("inserts a new library with autoassigned ID", func() {
				lib := &model.Library{
					ID:   0,
					Name: "Test Library",
					Path: "/music/test",
				}

				err := repo.Put(ctx, lib)
				Expect(err).ToNot(HaveOccurred())
				Expect(lib.ID).To(BeNumerically(">", 0))
				Expect(lib.CreatedAt).ToNot(BeZero())
				Expect(lib.UpdatedAt).ToNot(BeZero())

				// Verify it was inserted
				savedLib, err := repo.Get(ctx, lib.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(savedLib.Name).To(Equal("Test Library"))
				Expect(savedLib.Path).To(Equal("/music/test"))
			})
		})

		Context("when colsToUpdate is specified", func() {
			It("only writes the requested columns", func() {
				lib := &model.Library{
					Name:            "Original Library",
					Path:            "/music/original",
					RemotePath:      "/remote/original",
					DefaultNewUsers: true,
				}
				Expect(repo.Put(ctx, lib)).To(Succeed())

				Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed", Path: lib.Path}, "name", "path")).To(Succeed())

				saved, err := repo.Get(ctx, lib.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(saved.Name).To(Equal("Renamed"))
				Expect(saved.RemotePath).To(Equal("/remote/original"))
				Expect(saved.DefaultNewUsers).To(BeTrue())
			})
		})

		Context("when ID is non-zero and record exists", func() {
			It("updates the existing record", func() {
				// First create a library
				lib := &model.Library{
					ID:   0,
					Name: "Original Library",
					Path: "/music/original",
				}
				err := repo.Put(ctx, lib)
				Expect(err).ToNot(HaveOccurred())

				originalID := lib.ID
				originalCreatedAt := lib.CreatedAt

				// Ensure the update's timestamp is strictly greater than the
				// create's timestamp on platforms with coarse clock resolution
				// (Windows' time.Now() is millisecond-granular).
				time.Sleep(2 * time.Millisecond)

				// Now update it
				lib.Name = "Updated Library"
				lib.Path = "/music/updated"
				err = repo.Put(ctx, lib)
				Expect(err).ToNot(HaveOccurred())

				// Verify it was updated, not inserted
				Expect(lib.ID).To(Equal(originalID))
				Expect(lib.CreatedAt).To(Equal(originalCreatedAt))
				Expect(lib.UpdatedAt).To(BeTemporally(">", originalCreatedAt))

				// Verify the changes were saved
				savedLib, err := repo.Get(ctx, lib.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(savedLib.Name).To(Equal("Updated Library"))
				Expect(savedLib.Path).To(Equal("/music/updated"))
			})
		})

		Context("when ID is non-zero but record doesn't exist", func() {
			It("inserts a new record with the specified ID", func() {
				lib := &model.Library{
					ID:   999,
					Name: "New Library with ID",
					Path: "/music/new",
				}

				// Ensure the record doesn't exist
				_, err := repo.Get(ctx, 999)
				Expect(err).To(HaveOccurred())

				// Put should insert it
				err = repo.Put(ctx, lib)
				Expect(err).ToNot(HaveOccurred())
				Expect(lib.ID).To(Equal(999))
				Expect(lib.CreatedAt).ToNot(BeZero())
				Expect(lib.UpdatedAt).ToNot(BeZero())

				// Verify it was inserted with the correct ID
				savedLib, err := repo.Get(ctx, 999)
				Expect(err).ToNot(HaveOccurred())
				Expect(savedLib.ID).To(Equal(999))
				Expect(savedLib.Name).To(Equal("New Library with ID"))
				Expect(savedLib.Path).To(Equal("/music/new"))
			})
		})
	})

	Describe("StoreMusicFolder", func() {
		var libBefore *model.Library

		BeforeEach(func() {
			var err error
			libBefore, err = repo.Get(ctx, model.DefaultLibraryID)
			Expect(err).ToNot(HaveOccurred())

			DeferCleanup(configtest.SetupConfig())
			DeferCleanup(func() {
				_, _ = conn.NewQuery("update library set path = {:path}, updated_at = {:updated_at} where id = {:id}").
					Bind(dbx.Params{"path": libBefore.Path, "updated_at": libBefore.UpdatedAt, "id": model.DefaultLibraryID}).
					Execute()
				libLock.Lock()
				defer libLock.Unlock()
				delete(libCache, model.DefaultLibraryID)
			})
		})

		It("skips updating the default library when the configured path is unchanged", func() {
			conf.Server.MusicFolder = libBefore.Path
			Expect(repo.StoreMusicFolder(ctx)).To(Succeed())

			libAfter, err := repo.Get(ctx, model.DefaultLibraryID)
			Expect(err).ToNot(HaveOccurred())
			Expect(libAfter.Path).To(Equal(libBefore.Path))
			Expect(libAfter.UpdatedAt).To(Equal(libBefore.UpdatedAt))
		})

		It("updates the default library only when the configured path changes", func() {
			conf.Server.MusicFolder = libBefore.Path + "-updated"
			Expect(repo.StoreMusicFolder(ctx)).To(Succeed())

			libAfter, err := repo.Get(ctx, model.DefaultLibraryID)
			Expect(err).ToNot(HaveOccurred())
			Expect(libAfter.Path).To(Equal(conf.Server.MusicFolder))
			Expect(libAfter.UpdatedAt).ToNot(Equal(libBefore.UpdatedAt))
		})
	})

	It("refreshes stats", func() {
		libBefore, err := repo.Get(ctx, 1)
		Expect(err).ToNot(HaveOccurred())
		Expect(repo.RefreshStats(ctx, 1)).To(Succeed())
		libAfter, err := repo.Get(ctx, 1)
		Expect(err).ToNot(HaveOccurred())
		Expect(libAfter.UpdatedAt).To(BeTemporally(">", libBefore.UpdatedAt))

		var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 }
		var sizeRes struct{ Sum int64 }
		var durationRes struct{ Sum float64 }

		Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&songsRes)).To(Succeed())
		Expect(conn.NewQuery("select count(*) as count from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&albumsRes)).To(Succeed())
		Expect(conn.NewQuery("select count(*) as count from library_artist la join artist a on la.artist_id = a.id where la.library_id = {:id} and a.missing = 0").Bind(dbx.Params{"id": 1}).One(&artistsRes)).To(Succeed())
		Expect(conn.NewQuery("select count(*) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&foldersRes)).To(Succeed())
		Expect(conn.NewQuery("select ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&filesRes)).To(Succeed())
		Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 1").Bind(dbx.Params{"id": 1}).One(&missingRes)).To(Succeed())
		Expect(conn.NewQuery("select ifnull(sum(size),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&sizeRes)).To(Succeed())
		Expect(conn.NewQuery("select ifnull(sum(duration),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&durationRes)).To(Succeed())

		Expect(libAfter.TotalSongs).To(Equal(int(songsRes.Count)))
		Expect(libAfter.TotalAlbums).To(Equal(int(albumsRes.Count)))
		Expect(libAfter.TotalArtists).To(Equal(int(artistsRes.Count)))
		Expect(libAfter.TotalFolders).To(Equal(int(foldersRes.Count)))
		Expect(libAfter.TotalFiles).To(Equal(int(filesRes.Count)))
		Expect(libAfter.TotalMissingFiles).To(Equal(int(missingRes.Count)))
		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(ctx, lib)
			Expect(err).ToNot(HaveOccurred())
		})

		DescribeTable("ScanBegin",
			func(fullScan bool, expectedFullScanInProgress bool) {
				err := repo.ScanBegin(ctx, lib.ID, fullScan)
				Expect(err).ToNot(HaveOccurred())

				updatedLib, err := repo.Get(ctx, 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(ctx, lib.ID, true)
				Expect(err).ToNot(HaveOccurred())
			})

			It("sets LastScanAt and clears FullScanInProgress and LastScanStartedAt", func() {
				err := repo.ScanEnd(ctx, lib.ID)
				Expect(err).ToNot(HaveOccurred())

				updatedLib, err := repo.Get(ctx, 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(ctx, lib.ID)
				Expect(err).ToNot(HaveOccurred())

				err = repo.ScanEnd(ctx, lib.ID)
				Expect(err).ToNot(HaveOccurred())

				libAfter, err := repo.Get(ctx, lib.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(libAfter.LastScanAt).To(BeTemporally(">=", libBefore.LastScanStartedAt))
			})
		})
	})

	Describe("PID config", func() {
		It("stores the overrides, and Put never touches the scanned specs", func() {
			lib := &model.Library{Name: "PID Library", Path: "/music/pid", PIDAlbum: "folder", PIDTrack: "title"}
			Expect(repo.Put(ctx, lib)).To(Succeed())
			Expect(repo.SetScannedPID(ctx, lib.ID, model.PIDConfig{Album: "folder", Track: "title"})).To(Succeed())

			// An update coming from the REST API has no scanned specs. It must not clear them
			update := &model.Library{ID: lib.ID, Name: "PID Library", Path: "/music/pid", PIDTrack: "title"}
			Expect(repo.Put(ctx, update)).To(Succeed())

			saved, err := repo.Get(ctx, lib.ID)
			Expect(err).ToNot(HaveOccurred())
			Expect(saved.PIDAlbum).To(BeEmpty())
			Expect(saved.PIDTrack).To(Equal("title"))
			Expect(saved.ScannedPIDAlbum).To(Equal("folder"))
			Expect(saved.ScannedPIDTrack).To(Equal("title"))
		})

		It("keeps the overrides when a partial update does not send them", func() {
			lib := &model.Library{Name: "Partial", Path: "/music/partial", PIDAlbum: "folder", PIDTrack: "title"}
			Expect(repo.Put(ctx, lib)).To(Succeed())

			Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed"}, "name")).To(Succeed())

			saved, err := repo.Get(ctx, lib.ID)
			Expect(err).ToNot(HaveOccurred())
			Expect(saved.Name).To(Equal("Renamed"))
			Expect(saved.PIDAlbum).To(Equal("folder"))
			Expect(saved.PIDTrack).To(Equal("title"))
		})
	})

	Describe("Delete", func() {
		var adminRepo model.LibraryRepository
		var artistRepo model.ArtistRepository
		var adminCtx context.Context

		artistMissing := func(id string) bool {
			var missing bool
			err := conn.NewQuery("SELECT missing FROM artist WHERE id = {:id}").
				Bind(dbx.Params{"id": id}).Row(&missing)
			Expect(err).ToNot(HaveOccurred())
			return missing
		}

		BeforeEach(func() {
			adminCtx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
			adminRepo = NewLibraryRepository(conn)
			artistRepo = NewArtistRepository(conn)
		})

		It("marks artists orphaned by the delete as missing", func() {
			lib := model.Library{Name: "Doomed Library", Path: "/doomed"}
			Expect(adminRepo.Put(adminCtx, &lib)).To(Succeed())

			orphanArtist := model.Artist{ID: "delete-orphan", Name: "Orphan To Be"}
			sharedArtist := model.Artist{ID: "delete-shared", Name: "Shared Artist"}
			Expect(artistRepo.Put(adminCtx, &orphanArtist)).To(Succeed())
			Expect(artistRepo.Put(adminCtx, &sharedArtist)).To(Succeed())
			Expect(adminRepo.AddArtist(adminCtx, lib.ID, orphanArtist.ID)).To(Succeed())
			Expect(adminRepo.AddArtist(adminCtx, lib.ID, sharedArtist.ID)).To(Succeed())
			Expect(adminRepo.AddArtist(adminCtx, 1, sharedArtist.ID)).To(Succeed())
			DeferCleanup(func() {
				if raw, ok := artistRepo.(*artistRepository); ok {
					_, _ = raw.executeSQL(adminCtx, squirrel.Delete("artist").
						Where(squirrel.Eq{"id": []string{orphanArtist.ID, sharedArtist.ID}}))
				}
			})

			Expect(artistMissing(orphanArtist.ID)).To(BeFalse())

			Expect(adminRepo.Delete(adminCtx, lib.ID)).To(Succeed())

			Expect(artistMissing(orphanArtist.ID)).To(BeTrue(), "orphaned artist should be marked missing")
			Expect(artistMissing(sharedArtist.ID)).To(BeFalse(), "artist still in another library must stay visible")
		})
	})
})