cad9cdc53e
* fix: preserve created_at when moving songs between libraries (#5050) When songs are moved between libraries, their creation date was being reset to the current time, causing them to incorrectly appear in "Recently Added". Three changes fix this: 1. Add hash:"ignore" to AlbumID in MediaFile struct so that Equals() works for cross-library moves (AlbumID includes library prefix, making hashes always differ between libraries) 2. Preserve album created_at in moveMatched() via CopyAttributes, matching the pattern already used in persistAlbum() for within-library album ID changes 3. Only set CreatedAt in Put() when it's zero (new files), and explicitly copy missing.CreatedAt to the target in moveMatched() as defense-in-depth for the INSERT code path * test: add regression tests for created_at preservation (#5050) Add tests covering the three aspects of the fix: - Scanner: moveMatched preserves missing track's created_at - Scanner: CopyAttributes called for album created_at on album change - Scanner: CopyAttributes not called when album ID stays the same - Persistence: Put sets CreatedAt to now for new files with zero value - Persistence: Put preserves non-zero CreatedAt on insert - Persistence: Put does not reset CreatedAt on update Also adds CopyAttributes to MockAlbumRepo for test support. * test: verify album created_at is updated in cross-library move test (#5050) Added end-to-end assertion in the cross-library move test to verify that the new album's CreatedAt field is actually set to the original value after CopyAttributes runs, not just that the method was called. This strengthens the test by confirming the mock correctly propagates the timestamp.
715 lines
24 KiB
Go
715 lines
24 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"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/id"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
var _ = Describe("MediaRepository", func() {
|
|
var mr model.MediaFileRepository
|
|
|
|
BeforeEach(func() {
|
|
ctx := log.NewContext(context.TODO())
|
|
ctx = request.WithUser(ctx, model.User{ID: "userid"})
|
|
mr = NewMediaFileRepository(ctx, GetDBXBuilder())
|
|
})
|
|
|
|
It("gets mediafile from the DB", func() {
|
|
actual, err := mr.Get("1004")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
actual.CreatedAt = time.Time{}
|
|
Expect(actual).To(Equal(&songAntenna))
|
|
})
|
|
|
|
It("returns ErrNotFound", func() {
|
|
_, err := mr.Get("56")
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
})
|
|
|
|
It("counts the number of mediafiles in the DB", func() {
|
|
Expect(mr.CountAll()).To(Equal(int64(10)))
|
|
})
|
|
|
|
Describe("CountBySuffix", func() {
|
|
var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile
|
|
|
|
BeforeEach(func() {
|
|
mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "/test/file.mp3"}
|
|
flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "/test/file1.flac"}
|
|
flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "/test/file2.flac"}
|
|
flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "/test/file.FLAC"}
|
|
|
|
Expect(mr.Put(&mp3File)).To(Succeed())
|
|
Expect(mr.Put(&flacFile1)).To(Succeed())
|
|
Expect(mr.Put(&flacFile2)).To(Succeed())
|
|
Expect(mr.Put(&flacUpperFile)).To(Succeed())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
_ = mr.Delete(mp3File.ID)
|
|
_ = mr.Delete(flacFile1.ID)
|
|
_ = mr.Delete(flacFile2.ID)
|
|
_ = mr.Delete(flacUpperFile.ID)
|
|
})
|
|
|
|
It("counts media files grouped by suffix with lowercase normalization", func() {
|
|
counts, err := mr.CountBySuffix()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Should have lowercase keys only
|
|
Expect(counts).To(HaveKey("mp3"))
|
|
Expect(counts).To(HaveKey("flac"))
|
|
Expect(counts).ToNot(HaveKey("FLAC"))
|
|
|
|
// mp3: 1 file
|
|
Expect(counts["mp3"]).To(Equal(int64(1)))
|
|
// flac: 3 files (2 lowercase + 1 uppercase normalized)
|
|
Expect(counts["flac"]).To(Equal(int64(3)))
|
|
})
|
|
})
|
|
|
|
It("returns songs ordered by lyrics with a specific title/artist", func() {
|
|
// attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items
|
|
results, err := mr.GetAll(model.QueryOptions{
|
|
Sort: "lyrics, updated_at",
|
|
Order: "desc",
|
|
Filters: squirrel.And{
|
|
squirrel.Eq{"title": "Antenna"},
|
|
squirrel.Or{
|
|
Exists("json_tree(participants, '$.albumartist')", squirrel.Eq{"value": "Kraftwerk"}),
|
|
Exists("json_tree(participants, '$.artist')", squirrel.Eq{"value": "Kraftwerk"}),
|
|
},
|
|
},
|
|
})
|
|
|
|
Expect(err).To(BeNil())
|
|
Expect(results).To(HaveLen(3))
|
|
Expect(results[0].Lyrics).To(Equal(`[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`))
|
|
for _, item := range results[1:] {
|
|
Expect(item.Lyrics).To(Equal("[]"))
|
|
Expect(item.Title).To(Equal("Antenna"))
|
|
Expect(item.Participants[model.RoleArtist][0].Name).To(Equal("Kraftwerk"))
|
|
}
|
|
})
|
|
|
|
Describe("Put CreatedAt behavior (#5050)", func() {
|
|
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
|
|
before := time.Now().Add(-time.Second)
|
|
newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"}
|
|
Expect(mr.Put(&newFile)).To(Succeed())
|
|
|
|
retrieved, err := mr.Get(newFile.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(retrieved.CreatedAt).To(BeTemporally(">", before))
|
|
|
|
_ = mr.Delete(newFile.ID)
|
|
})
|
|
|
|
It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() {
|
|
originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
|
|
newFile := model.MediaFile{
|
|
ID: id.NewRandom(),
|
|
LibraryID: 1,
|
|
Path: "/test/created-at-preserved.mp3",
|
|
CreatedAt: originalTime,
|
|
}
|
|
Expect(mr.Put(&newFile)).To(Succeed())
|
|
|
|
retrieved, err := mr.Get(newFile.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
|
|
|
|
_ = mr.Delete(newFile.ID)
|
|
})
|
|
|
|
It("does not reset CreatedAt when updating an existing file", func() {
|
|
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
|
|
fileID := id.NewRandom()
|
|
newFile := model.MediaFile{
|
|
ID: fileID,
|
|
LibraryID: 1,
|
|
Path: "/test/created-at-update.mp3",
|
|
Title: "Original Title",
|
|
CreatedAt: originalTime,
|
|
}
|
|
Expect(mr.Put(&newFile)).To(Succeed())
|
|
|
|
// Update the file with a new title but zero CreatedAt
|
|
updatedFile := model.MediaFile{
|
|
ID: fileID,
|
|
LibraryID: 1,
|
|
Path: "/test/created-at-update.mp3",
|
|
Title: "Updated Title",
|
|
// CreatedAt is zero - should NOT overwrite the stored value
|
|
}
|
|
Expect(mr.Put(&updatedFile)).To(Succeed())
|
|
|
|
retrieved, err := mr.Get(fileID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(retrieved.Title).To(Equal("Updated Title"))
|
|
// CreatedAt should still be the original time (not reset)
|
|
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
|
|
|
|
_ = mr.Delete(fileID)
|
|
})
|
|
})
|
|
|
|
It("checks existence of mediafiles in the DB", func() {
|
|
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
|
|
Expect(mr.Exists("666")).To(BeFalse())
|
|
})
|
|
|
|
It("delete tracks by id", func() {
|
|
newID := id.NewRandom()
|
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed())
|
|
|
|
Expect(mr.Delete(newID)).To(Succeed())
|
|
|
|
_, err := mr.Get(newID)
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
})
|
|
|
|
It("deletes all missing files", func() {
|
|
new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
|
|
new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
|
|
Expect(mr.Put(&new1)).To(Succeed())
|
|
Expect(mr.Put(&new2)).To(Succeed())
|
|
Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed())
|
|
|
|
adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true})
|
|
adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder())
|
|
|
|
// Ensure the files are marked as missing and we have 2 of them
|
|
count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})
|
|
Expect(count).To(BeNumerically("==", 2))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
count, err = adminRepo.DeleteAllMissing()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(count).To(BeNumerically("==", 2))
|
|
|
|
_, err = mr.Get(new1.ID)
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
_, err = mr.Get(new2.ID)
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
})
|
|
|
|
Context("Annotations", func() {
|
|
It("increments play count when the tracks does not have annotations", func() {
|
|
id := "incplay.firsttime"
|
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
|
playDate := time.Now()
|
|
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
|
|
|
mf, err := mr.Get(id)
|
|
Expect(err).To(BeNil())
|
|
|
|
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
|
Expect(mf.PlayCount).To(Equal(int64(1)))
|
|
})
|
|
|
|
Describe("AverageRating", func() {
|
|
var raw *mediaFileRepository
|
|
|
|
BeforeEach(func() {
|
|
raw = mr.(*mediaFileRepository)
|
|
})
|
|
|
|
It("returns 0 when no ratings exist", func() {
|
|
newID := id.NewRandom()
|
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/no-rating.mp3"})).To(Succeed())
|
|
|
|
mf, err := mr.Get(newID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mf.AverageRating).To(Equal(0.0))
|
|
|
|
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
|
})
|
|
|
|
It("returns the user's rating as average when only one user rated", func() {
|
|
newID := id.NewRandom()
|
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/single-rating.mp3"})).To(Succeed())
|
|
Expect(mr.SetRating(5, newID)).To(Succeed())
|
|
|
|
mf, err := mr.Get(newID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mf.AverageRating).To(Equal(5.0))
|
|
|
|
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
|
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
|
})
|
|
|
|
It("calculates average across multiple users", func() {
|
|
newID := id.NewRandom()
|
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/multi-rating.mp3"})).To(Succeed())
|
|
|
|
Expect(mr.SetRating(3, newID)).To(Succeed())
|
|
|
|
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
|
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
|
|
Expect(user2Repo.SetRating(5, newID)).To(Succeed())
|
|
|
|
mf, err := mr.Get(newID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mf.AverageRating).To(Equal(4.0))
|
|
|
|
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
|
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
|
})
|
|
|
|
It("excludes zero ratings from average calculation", func() {
|
|
newID := id.NewRandom()
|
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/zero-excluded.mp3"})).To(Succeed())
|
|
|
|
Expect(mr.SetRating(4, newID)).To(Succeed())
|
|
|
|
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
|
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
|
|
Expect(user2Repo.SetRating(0, newID)).To(Succeed())
|
|
|
|
mf, err := mr.Get(newID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mf.AverageRating).To(Equal(4.0))
|
|
|
|
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
|
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
|
})
|
|
})
|
|
|
|
It("preserves play date if and only if provided date is older", func() {
|
|
id := "incplay.playdate"
|
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
|
playDate := time.Now()
|
|
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
|
mf, err := mr.Get(id)
|
|
Expect(err).To(BeNil())
|
|
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
|
Expect(mf.PlayCount).To(Equal(int64(1)))
|
|
|
|
playDateLate := playDate.AddDate(0, 0, 1)
|
|
Expect(mr.IncPlayCount(id, playDateLate)).To(BeNil())
|
|
mf, err = mr.Get(id)
|
|
Expect(err).To(BeNil())
|
|
Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix()))
|
|
Expect(mf.PlayCount).To(Equal(int64(2)))
|
|
|
|
playDateEarly := playDate.AddDate(0, 0, -1)
|
|
Expect(mr.IncPlayCount(id, playDateEarly)).To(BeNil())
|
|
mf, err = mr.Get(id)
|
|
Expect(err).To(BeNil())
|
|
Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix()))
|
|
Expect(mf.PlayCount).To(Equal(int64(3)))
|
|
})
|
|
|
|
It("increments play count on newly starred items", func() {
|
|
id := "star.incplay"
|
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
|
Expect(mr.SetStar(true, id)).To(BeNil())
|
|
playDate := time.Now()
|
|
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
|
|
|
mf, err := mr.Get(id)
|
|
Expect(err).To(BeNil())
|
|
|
|
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
|
Expect(mf.PlayCount).To(Equal(int64(1)))
|
|
})
|
|
})
|
|
|
|
Context("Sort options", func() {
|
|
Context("recently_added sort", func() {
|
|
var testMediaFiles []model.MediaFile
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
|
|
// Create test media files with specific timestamps
|
|
testMediaFiles = []model.MediaFile{
|
|
{
|
|
ID: id.NewRandom(),
|
|
LibraryID: 1,
|
|
Title: "Old Song",
|
|
Path: "/test/old.mp3",
|
|
},
|
|
{
|
|
ID: id.NewRandom(),
|
|
LibraryID: 1,
|
|
Title: "Middle Song",
|
|
Path: "/test/middle.mp3",
|
|
},
|
|
{
|
|
ID: id.NewRandom(),
|
|
LibraryID: 1,
|
|
Title: "New Song",
|
|
Path: "/test/new.mp3",
|
|
},
|
|
}
|
|
|
|
// Insert test data first
|
|
for i := range testMediaFiles {
|
|
Expect(mr.Put(&testMediaFiles[i])).To(Succeed())
|
|
}
|
|
|
|
// Then manually update timestamps using direct SQL to bypass the repository logic
|
|
db := GetDBXBuilder()
|
|
|
|
// Set specific timestamps for testing
|
|
oldTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
middleTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
newTime := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
// Update "Old Song": created long ago, updated recently
|
|
_, err := db.Update("media_file",
|
|
map[string]any{
|
|
"created_at": oldTime,
|
|
"updated_at": newTime,
|
|
},
|
|
dbx.HashExp{"id": testMediaFiles[0].ID}).Execute()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Update "Middle Song": created and updated at the same middle time
|
|
_, err = db.Update("media_file",
|
|
map[string]any{
|
|
"created_at": middleTime,
|
|
"updated_at": middleTime,
|
|
},
|
|
dbx.HashExp{"id": testMediaFiles[1].ID}).Execute()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Update "New Song": created recently, updated long ago
|
|
_, err = db.Update("media_file",
|
|
map[string]any{
|
|
"created_at": newTime,
|
|
"updated_at": oldTime,
|
|
},
|
|
dbx.HashExp{"id": testMediaFiles[2].ID}).Execute()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Clean up test data
|
|
for _, mf := range testMediaFiles {
|
|
_ = mr.Delete(mf.ID)
|
|
}
|
|
})
|
|
|
|
When("RecentlyAddedByModTime is false", func() {
|
|
var testRepo model.MediaFileRepository
|
|
|
|
BeforeEach(func() {
|
|
conf.Server.RecentlyAddedByModTime = false
|
|
// Create repository AFTER setting config
|
|
ctx := log.NewContext(GinkgoT().Context())
|
|
ctx = request.WithUser(ctx, model.User{ID: "userid"})
|
|
testRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
|
|
})
|
|
|
|
It("sorts by created_at", func() {
|
|
// Get results sorted by recently_added (should use created_at)
|
|
results, err := testRepo.GetAll(model.QueryOptions{
|
|
Sort: "recently_added",
|
|
Order: "desc",
|
|
Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(3))
|
|
|
|
// Verify sorting by created_at (newest first in descending order)
|
|
Expect(results[0].Title).To(Equal("New Song")) // created 2022
|
|
Expect(results[1].Title).To(Equal("Middle Song")) // created 2021
|
|
Expect(results[2].Title).To(Equal("Old Song")) // created 2020
|
|
})
|
|
|
|
It("sorts in ascending order when specified", func() {
|
|
// Get results sorted by recently_added in ascending order
|
|
results, err := testRepo.GetAll(model.QueryOptions{
|
|
Sort: "recently_added",
|
|
Order: "asc",
|
|
Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(3))
|
|
|
|
// Verify sorting by created_at (oldest first)
|
|
Expect(results[0].Title).To(Equal("Old Song")) // created 2020
|
|
Expect(results[1].Title).To(Equal("Middle Song")) // created 2021
|
|
Expect(results[2].Title).To(Equal("New Song")) // created 2022
|
|
})
|
|
})
|
|
|
|
When("RecentlyAddedByModTime is true", func() {
|
|
var testRepo model.MediaFileRepository
|
|
|
|
BeforeEach(func() {
|
|
conf.Server.RecentlyAddedByModTime = true
|
|
// Create repository AFTER setting config
|
|
ctx := log.NewContext(GinkgoT().Context())
|
|
ctx = request.WithUser(ctx, model.User{ID: "userid"})
|
|
testRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
|
|
})
|
|
|
|
It("sorts by updated_at", func() {
|
|
// Get results sorted by recently_added (should use updated_at)
|
|
results, err := testRepo.GetAll(model.QueryOptions{
|
|
Sort: "recently_added",
|
|
Order: "desc",
|
|
Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(3))
|
|
|
|
// Verify sorting by updated_at (newest first in descending order)
|
|
Expect(results[0].Title).To(Equal("Old Song")) // updated 2022
|
|
Expect(results[1].Title).To(Equal("Middle Song")) // updated 2021
|
|
Expect(results[2].Title).To(Equal("New Song")) // updated 2020
|
|
})
|
|
})
|
|
|
|
})
|
|
})
|
|
|
|
Context("Filters", func() {
|
|
var mfWithoutAnnotation model.MediaFile
|
|
|
|
BeforeEach(func() {
|
|
mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "/test/no-annotation.mp3", Title: "No Annotation"}
|
|
Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
_ = mr.Delete(mfWithoutAnnotation.ID)
|
|
})
|
|
|
|
Describe("starred", func() {
|
|
It("false includes items without annotations", func() {
|
|
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
|
Filters: map[string]any{"starred": "false"},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
files := res.(model.MediaFiles)
|
|
|
|
var found bool
|
|
for _, f := range files {
|
|
if f.ID == mfWithoutAnnotation.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
Expect(found).To(BeTrue(), "MediaFile without annotation should be included in starred=false filter")
|
|
})
|
|
|
|
It("true excludes items without annotations", func() {
|
|
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
|
Filters: map[string]any{"starred": "true"},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
files := res.(model.MediaFiles)
|
|
|
|
for _, f := range files {
|
|
Expect(f.ID).ToNot(Equal(mfWithoutAnnotation.ID))
|
|
}
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("Search", func() {
|
|
Context("text search", func() {
|
|
It("finds media files by title", func() {
|
|
results, err := mr.Search("Antenna", 0, 10)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
|
|
for _, result := range results {
|
|
Expect(result.Title).To(Equal("Antenna"))
|
|
}
|
|
})
|
|
|
|
It("finds media files case insensitively", func() {
|
|
results, err := mr.Search("antenna", 0, 10)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(3))
|
|
for _, result := range results {
|
|
Expect(result.Title).To(Equal("Antenna"))
|
|
}
|
|
})
|
|
|
|
It("returns empty result when no matches found", func() {
|
|
results, err := mr.Search("nonexistent", 0, 10)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Context("MBID search", func() {
|
|
var mediaFileWithMBID model.MediaFile
|
|
var raw *mediaFileRepository
|
|
|
|
BeforeEach(func() {
|
|
raw = mr.(*mediaFileRepository)
|
|
// Create a test media file with MBID
|
|
mediaFileWithMBID = model.MediaFile{
|
|
ID: "test-mbid-mediafile",
|
|
Title: "Test MBID MediaFile",
|
|
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4
|
|
MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4
|
|
LibraryID: 1,
|
|
Path: "/test/path/test.mp3",
|
|
}
|
|
|
|
// Insert the test media file into the database
|
|
err := mr.Put(&mediaFileWithMBID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Clean up test data using direct SQL
|
|
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID}))
|
|
})
|
|
|
|
It("finds media file by mbz_recording_id", func() {
|
|
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(1))
|
|
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
|
|
Expect(results[0].Title).To(Equal("Test MBID MediaFile"))
|
|
})
|
|
|
|
It("finds media file by mbz_release_track_id", func() {
|
|
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(1))
|
|
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
|
|
Expect(results[0].Title).To(Equal("Test MBID MediaFile"))
|
|
})
|
|
|
|
It("returns empty result when MBID is not found", func() {
|
|
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(BeEmpty())
|
|
})
|
|
|
|
It("missing media files are never returned by search", func() {
|
|
// Create a missing media file with MBID
|
|
missingMediaFile := model.MediaFile{
|
|
ID: "test-missing-mbid-mediafile",
|
|
Title: "Test Missing MBID MediaFile",
|
|
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022",
|
|
LibraryID: 1,
|
|
Path: "/test/path/missing.mp3",
|
|
Missing: true,
|
|
}
|
|
|
|
err := mr.Put(&missingMediaFile)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Search never returns missing media files (hardcoded behavior)
|
|
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(BeEmpty())
|
|
|
|
// Clean up
|
|
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID}))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("FindByPaths", func() {
|
|
// Test fixtures for Unicode and case-sensitivity tests
|
|
var testFiles []model.MediaFile
|
|
|
|
BeforeEach(func() {
|
|
testFiles = []model.MediaFile{
|
|
{ID: "findpath-1", LibraryID: 1, Path: "artist/Album/track.mp3", Title: "Track"},
|
|
{ID: "findpath-2", LibraryID: 1, Path: "artist/Album/UPPER.mp3", Title: "Upper"},
|
|
// Fullwidth uppercase: ACROSS (U+FF21 U+FF23 U+FF32 U+FF2F U+FF33 U+FF33)
|
|
{ID: "findpath-3", LibraryID: 1, Path: "plex/02 - ACROSS.flac", Title: "Fullwidth"},
|
|
// French diacritic: è (U+00E8, can decompose to e + combining grave)
|
|
{ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"},
|
|
}
|
|
for _, mf := range testFiles {
|
|
Expect(mr.Put(&mf)).To(Succeed())
|
|
}
|
|
})
|
|
|
|
AfterEach(func() {
|
|
for _, mf := range testFiles {
|
|
_ = mr.Delete(mf.ID)
|
|
}
|
|
})
|
|
|
|
It("finds files by exact path", func() {
|
|
results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(1))
|
|
Expect(results[0].ID).To(Equal("findpath-1"))
|
|
})
|
|
|
|
It("finds files case-insensitively for ASCII characters (NOCASE)", func() {
|
|
// SQLite's COLLATE NOCASE handles ASCII case-insensitivity
|
|
results, err := mr.FindByPaths([]string{"1:ARTIST/ALBUM/TRACK.MP3"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(1))
|
|
Expect(results[0].ID).To(Equal("findpath-1"))
|
|
})
|
|
|
|
It("finds fullwidth characters only with exact case match (SQLite NOCASE limitation)", func() {
|
|
// SQLite's NOCASE does NOT handle fullwidth uppercase/lowercase equivalence
|
|
// The DB has fullwidth uppercase ACROSS, searching with exact match should work
|
|
results, err := mr.FindByPaths([]string{"1:plex/02 - ACROSS.flac"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(1))
|
|
Expect(results[0].ID).To(Equal("findpath-3"))
|
|
|
|
// Searching with fullwidth lowercase across should NOT match
|
|
// (this is the SQLite limitation that requires exact matching for non-ASCII)
|
|
results, err = mr.FindByPaths([]string{"1:plex/02 - across.flac"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(BeEmpty())
|
|
})
|
|
|
|
It("returns multiple files when querying multiple paths", func() {
|
|
results, err := mr.FindByPaths([]string{
|
|
"1:artist/Album/track.mp3",
|
|
"1:artist/Album/UPPER.mp3",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(2))
|
|
})
|
|
|
|
It("returns empty slice for non-existent paths", func() {
|
|
results, err := mr.FindByPaths([]string{"1:nonexistent/path.mp3"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(BeEmpty())
|
|
})
|
|
|
|
It("returns empty slice for empty input", func() {
|
|
results, err := mr.FindByPaths([]string{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(BeEmpty())
|
|
})
|
|
|
|
It("handles library-qualified paths correctly", func() {
|
|
// Library 1 should find the file
|
|
results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(1))
|
|
|
|
// Library 2 should NOT find it (file is in library 1)
|
|
results, err = mr.FindByPaths([]string{"2:artist/Album/track.mp3"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|