mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* fix(playlist): preserve smart playlist counters on re-import (#5907) * perf(playlist): skip re-importing unchanged NSP files (#5907) * feat(playlist): also store content hash for M3U imports (unused for now) * fix(playlist): return stored record when skipping unchanged NSP import Skipping before copying the stored identity broke the ImportFile(sync=false) contract: callers received an ID-less playlist and the requested Sync change was silently dropped. * refactor(playlist): hash imports once at the caller; protect smart counters in Put Move content hashing out of both parsers into the code that owns the file (parsePlaylist and ImportFile), removing the NSP double-buffer and the duplicated hashing idiom. Put now drops song_count/duration/size for smart playlists (PostMapArgs), disarming the counter-zeroing trap for all callers. * fix(playlist): invalidate imported hash when rules are edited via API Without this, a rules edit through the REST API kept the stored file hash, so every scan skipped the unchanged file and never restored the file-backed rules while sync was on. * test(playlist): verify smart counters survive a re-import, end to end The existing Put test seeds the stored counters with a raw SQL update, so it pins the guard in PostMapArgs but not the pipeline around it. This test drives the counters through a real evaluation instead: it saves a smart playlist, reads it with GetWithTracks to populate song_count/duration/size, then saves the playlist the way the scanner rebuilds it after parsing the .nsp file, with the counters back at zero. Both routes fail without the guard, and the new one covers the exact sequence reported in #5907. Test taken from #5970, which diagnosed the same root cause independently. Co-authored-by: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com> * test(playlist): build the service with artwork.NewUploader The artwork pipeline in #5847 replaced core.NewImageUploadService() with artwork.NewUploader(ds) and updated every call site it could see. The five call sites this branch adds were written against the old constructor, so the merge applied cleanly but left the package uncompilable. * fix(db): re-stamp the imported_hash migration after the master merge Master gained three migrations while this branch was open, the newest being 20260816180040. The original 20260808200333 stamp now sorts before them, so any database already upgraded past that point would skip this migration entirely and never get the imported_hash column. Same SQL, current timestamp. * refactor(playlist): hash imported playlists with xxh3 and the id encoding ImportedHash is a change detector, not a security boundary, so it does not need a cryptographic digest. xxh3 is already a direct dependency and is used the same way to fingerprint files in the artwork image store. Encoding the 128-bit digest with id.Encode stores it in the same 22-char base62 form as every other id in the schema, down from 64 hex chars. No migration is needed: the imported_hash column has not shipped in a release, so no database holds a value in the old format. * refactor(playlist): extract the imported-playlist fingerprint helper Both import paths encoded the hash inline, so how a playlist file is fingerprinted lived in two places. A third import path that encoded it differently would silently never match the stored value, turning the unchanged-file skip into a no-op. --------- Co-authored-by: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com>
This commit is contained in:
parent
4b1218eec0
commit
2e03766a9d
@ -12,8 +12,10 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"github.com/zeebo/xxh3"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
@ -57,10 +59,12 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
hasher := xxh3.New()
|
||||
reader := io.TeeReader(ioutils.UTF8Reader(file), hasher)
|
||||
if err := s.parseM3U(ctx, pls, nil, reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pls.ImportedHash = fingerprint(hasher)
|
||||
if err := s.updatePlaylist(ctx, pls, sync); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -138,7 +142,9 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
// Hash the bytes the parser consumes, giving every imported playlist a content fingerprint
|
||||
hasher := xxh3.New()
|
||||
reader := io.TeeReader(ioutils.UTF8Reader(file), hasher)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
@ -146,7 +152,15 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
if err != nil {
|
||||
return pls, err
|
||||
}
|
||||
pls.ImportedHash = fingerprint(hasher)
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func fingerprint(h *xxh3.Hasher) string {
|
||||
return id.Encode(h.Sum128().Bytes())
|
||||
}
|
||||
|
||||
// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode
|
||||
@ -179,6 +193,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// Only smart playlists skip on an unchanged file; M3U must re-run so newly-added tracks resolve.
|
||||
if !forceSync && newPls.IsSmartPlaylist() && newPls.ImportedHash != "" && newPls.ImportedHash == pls.ImportedHash {
|
||||
log.Trace(ctx, "Playlist file unchanged since last import, skipping", "playlist", pls.Name, "path", pls.Path)
|
||||
*newPls = *pls // callers must see the stored record, so e.g. ImportFile can still flip Sync
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||
newPls.ID = pls.ID
|
||||
newPls.Name = pls.Name
|
||||
@ -187,6 +207,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
|
||||
newPls.Public = pls.Public
|
||||
newPls.UploadedImage = pls.UploadedImage // Preserve manual upload
|
||||
newPls.EvaluatedAt = nil // force re-evaluation on next read
|
||||
if newPls.IsSmartPlaylist() {
|
||||
// Tracks aren't materialized at parse time; carry the stored counters so callers see real values
|
||||
newPls.SongCount = pls.SongCount
|
||||
newPls.Duration = pls.Duration
|
||||
newPls.Size = pls.Size
|
||||
}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
|
||||
@ -15,10 +15,12 @@ import (
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/zeebo/xxh3"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
@ -307,6 +309,33 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.ID).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("stores a content hash but re-imports unchanged M3U playlists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
|
||||
first, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(first.ImportedHash).ToNot(BeEmpty())
|
||||
|
||||
// Re-import with a matching stored hash: M3U must still be re-imported, not skipped.
|
||||
existingPls := &model.Playlist{
|
||||
ID: "m3u-id", Name: "Test", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", ImportedHash: first.ImportedHash,
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
mockPlsRepo.Last = nil
|
||||
|
||||
_, err = ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("clears ExternalImageURL on re-scan when directive is removed", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
@ -371,6 +400,85 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
|
||||
})
|
||||
|
||||
It("preserves counters when re-importing an existing smart playlist", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}],"sort":"title","order":"asc"}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id",
|
||||
Name: "My Smart",
|
||||
Path: plsFile,
|
||||
Sync: true,
|
||||
OwnerID: "123",
|
||||
SongCount: 42,
|
||||
Duration: 123.4,
|
||||
Size: 5000,
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil())
|
||||
Expect(mockPlsRepo.Last.IsSmartPlaylist()).To(BeTrue())
|
||||
Expect(mockPlsRepo.Last.SongCount).To(Equal(42))
|
||||
Expect(mockPlsRepo.Last.Duration).To(Equal(float32(123.4)))
|
||||
Expect(mockPlsRepo.Last.Size).To(Equal(int64(5000)))
|
||||
})
|
||||
|
||||
It("skips re-import when the smart playlist file content is unchanged", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", SongCount: 42,
|
||||
ImportedHash: hashOf(nsp),
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).To(BeNil()) // Put never called: nothing re-written
|
||||
})
|
||||
|
||||
It("re-imports when the smart playlist file content changed", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", SongCount: 42,
|
||||
ImportedHash: hashOf("old content"),
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil()) // Put called: file changed
|
||||
Expect(mockPlsRepo.Last.ImportedHash).To(Equal(hashOf(nsp)))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
|
||||
@ -760,6 +868,37 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.ID).To(Equal("existing-id"))
|
||||
Expect(pls.Sync).To(BeTrue())
|
||||
})
|
||||
|
||||
It("unsyncs a synced smart playlist with sync=false even when content is unchanged", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
|
||||
mockFolderRepo := &mockFolderRepoForImport{
|
||||
folder: &model.Folder{
|
||||
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
|
||||
},
|
||||
}
|
||||
ds.MockedFolder = mockFolderRepo
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", SongCount: 42,
|
||||
ImportedHash: hashOf(nsp),
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
pls, err := ps.ImportFile(ctx, plsFile, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ID).To(Equal("smart-id"))
|
||||
Expect(pls.Sync).To(BeFalse())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil())
|
||||
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ImportM3U", func() {
|
||||
@ -1083,3 +1222,7 @@ func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.F
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func hashOf(content string) string {
|
||||
return id.Encode(xxh3.Hash128([]byte(content)).Bytes())
|
||||
}
|
||||
|
||||
@ -136,6 +136,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod
|
||||
if rulesChanged {
|
||||
current.Rules = entity.Rules
|
||||
current.EvaluatedAt = nil // force re-evaluation on next read
|
||||
current.ImportedHash = "" // rules no longer match the source file; next scan must re-import it
|
||||
}
|
||||
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
|
||||
@ -160,6 +160,25 @@ var _ = Describe("REST Adapter", func() {
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
})
|
||||
|
||||
It("invalidates the imported hash when rules change, so the next scan re-syncs the file", func() {
|
||||
mockPlsRepo.Data["smart-1"] = &model.Playlist{
|
||||
ID: "smart-1",
|
||||
Name: "Smart Playlist",
|
||||
OwnerID: "user-1",
|
||||
Path: "/music/smart.nsp",
|
||||
Sync: true,
|
||||
ImportedHash: hashOf("file content"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}}
|
||||
pls := &model.Playlist{Rules: newRules}
|
||||
err := repo.Update("smart-1", pls, "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.ImportedHash).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("allows toggling sync for file-backed playlists", func() {
|
||||
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
mockPlsRepo.Data["file-pls"] = &model.Playlist{
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
alter table playlist add imported_hash varchar default '' not null;
|
||||
|
||||
-- +goose Down
|
||||
alter table playlist drop column imported_hash;
|
||||
@ -31,6 +31,7 @@ type Playlist struct {
|
||||
ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
ImportedHash string `structs:"imported_hash" json:"-"`
|
||||
|
||||
// SmartPlaylist attributes
|
||||
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
||||
|
||||
@ -39,6 +39,10 @@ func (p dbPlaylist) PostMapArgs(args map[string]any) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid criteria expression: %w", err)
|
||||
}
|
||||
// Smart playlist counters are owned by refreshCounters (evaluation), never by callers
|
||||
delete(args, "song_count")
|
||||
delete(args, "duration")
|
||||
delete(args, "size")
|
||||
return nil
|
||||
}
|
||||
delete(args, "rules")
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -230,6 +231,32 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put", func() {
|
||||
It("does not overwrite counters when saving a smart playlist", func() {
|
||||
pls := model.Playlist{Name: "Smart Counters", OwnerID: "userid", Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.Contains{"title": "love"}},
|
||||
}}
|
||||
Expect(repo.Put(&pls)).To(Succeed())
|
||||
DeferCleanup(func() { Expect(repo.Delete(pls.ID)).To(Succeed()) })
|
||||
|
||||
// Simulate a previous evaluation having stored the counters
|
||||
_, err := GetDBXBuilder().NewQuery("update playlist set song_count = 42, duration = 123, size = 456 where id = {:id}").
|
||||
Bind(dbx.Params{"id": pls.ID}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
pls.SongCount = 0
|
||||
pls.Duration = 0
|
||||
pls.Size = 0
|
||||
Expect(repo.Put(&pls)).To(Succeed())
|
||||
|
||||
saved, err := repo.Get(pls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(saved.SongCount).To(Equal(42))
|
||||
Expect(saved.Duration).To(Equal(float32(123)))
|
||||
Expect(saved.Size).To(Equal(int64(456)))
|
||||
})
|
||||
})
|
||||
|
||||
It("Put/Exists/Delete", func() {
|
||||
By("saves the playlist to the DB")
|
||||
newPls := model.Playlist{Name: "Great!", OwnerID: "userid"}
|
||||
|
||||
@ -58,6 +58,41 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("re-imported from disk", func() {
|
||||
// The scanner re-imports every playlist in a touched folder, and a freshly parsed
|
||||
// .nsp carries no counters — saving it must not wipe the ones already evaluated.
|
||||
It("keeps the stored counters when a freshly parsed playlist is saved over it", func() {
|
||||
rules = &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Contains{"title": "Antenna"},
|
||||
},
|
||||
}
|
||||
pls := model.Playlist{Name: "Smart", OwnerID: "userid", Rules: rules, Path: "/music/smart.nsp", Sync: true}
|
||||
Expect(repo.Put(&pls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(pls.ID) })
|
||||
|
||||
evaluated, err := repo.GetWithTracks(pls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(evaluated.SongCount).To(BeNumerically(">", 0))
|
||||
|
||||
stored, err := repo.Get(pls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored.SongCount).To(Equal(evaluated.SongCount))
|
||||
|
||||
reimported := model.Playlist{
|
||||
ID: pls.ID, Name: pls.Name, OwnerID: "userid", Rules: rules,
|
||||
Path: pls.Path, Sync: true,
|
||||
}
|
||||
Expect(repo.Put(&reimported)).To(Succeed())
|
||||
|
||||
afterImport, err := repo.Get(pls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(afterImport.SongCount).To(Equal(stored.SongCount))
|
||||
Expect(afterImport.Duration).To(Equal(stored.Duration))
|
||||
Expect(afterImport.Size).To(Equal(stored.Size))
|
||||
})
|
||||
})
|
||||
|
||||
Context("child smart playlists", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user