mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-15 22:07:08 +02:00
317 lines
11 KiB
Go
317 lines
11 KiB
Go
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(ctx, 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(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(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(lib)).To(Succeed())
|
|
|
|
Expect(repo.Put(&model.Library{ID: lib.ID, Name: "Renamed", Path: lib.Path}, "name", "path")).To(Succeed())
|
|
|
|
saved, err := repo.Get(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(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(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(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(999)
|
|
Expect(err).To(HaveOccurred())
|
|
|
|
// Put should insert it
|
|
err = repo.Put(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(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(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()).To(Succeed())
|
|
|
|
libAfter, err := repo.Get(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()).To(Succeed())
|
|
|
|
libAfter, err := repo.Get(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(1)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(repo.RefreshStats(1)).To(Succeed())
|
|
libAfter, err := repo.Get(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(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))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("Delete", func() {
|
|
var adminRepo model.LibraryRepository
|
|
var artistRepo model.ArtistRepository
|
|
|
|
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(context.TODO()), adminUser)
|
|
adminRepo = NewLibraryRepository(adminCtx, conn)
|
|
artistRepo = NewArtistRepository(adminCtx, conn)
|
|
})
|
|
|
|
It("marks artists orphaned by the delete as missing", func() {
|
|
lib := model.Library{Name: "Doomed Library", Path: "/doomed"}
|
|
Expect(adminRepo.Put(&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(&orphanArtist)).To(Succeed())
|
|
Expect(artistRepo.Put(&sharedArtist)).To(Succeed())
|
|
Expect(adminRepo.AddArtist(lib.ID, orphanArtist.ID)).To(Succeed())
|
|
Expect(adminRepo.AddArtist(lib.ID, sharedArtist.ID)).To(Succeed())
|
|
Expect(adminRepo.AddArtist(1, sharedArtist.ID)).To(Succeed())
|
|
DeferCleanup(func() {
|
|
if raw, ok := artistRepo.(*artistRepository); ok {
|
|
_, _ = raw.executeSQL(squirrel.Delete("artist").
|
|
Where(squirrel.Eq{"id": []string{orphanArtist.ID, sharedArtist.ID}}))
|
|
}
|
|
})
|
|
|
|
Expect(artistMissing(orphanArtist.ID)).To(BeFalse())
|
|
|
|
Expect(adminRepo.Delete(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")
|
|
})
|
|
})
|
|
})
|