b59eb32961
* fix(subsonic): optimize search3 for high-cardinality FTS queries Use a two-phase query strategy for FTS5 searches to avoid the performance penalty of expensive LEFT JOINs (annotation, bookmark, library) on high-cardinality results like "the". Phase 1 runs a lightweight query (main table + FTS index only) to get sorted, paginated rowids. Phase 2 hydrates only those few rowids with the full JOINs, making them nearly free. For queries with complex ORDER BY expressions that reference joined tables (e.g. artist search sorted by play count), the optimization is skipped and the original single-query approach is used. * fix(search): update order by clauses to include 'rank' for FTS queries Signed-off-by: Deluan <deluan@navidrome.org> * fix(search): reintroduce 'rank' in Phase 2 ORDER BY for FTS queries Signed-off-by: Deluan <deluan@navidrome.org> * fix(search): remove 'rank' from ORDER BY in non-FTS queries and adjust two-phase query handling Signed-off-by: Deluan <deluan@navidrome.org> * fix(search): update FTS ranking to use bm25 weights and simplify ORDER BY qualification Signed-off-by: Deluan <deluan@navidrome.org> * fix(search): refine FTS query handling and improve comments for clarity Signed-off-by: Deluan <deluan@navidrome.org> * fix(search): refactor full-text search handling to streamline query strategy selection and improve LIKE fallback logic. Increase e2e coverage for search3 Signed-off-by: Deluan <deluan@navidrome.org> * refactor: enhance FTS column definitions and relevance weights Signed-off-by: Deluan <deluan@navidrome.org> * fix(search): refactor Search method signatures to remove offset and size parameters, streamline query handling Signed-off-by: Deluan <deluan@navidrome.org> * fix(search): allow single-character queries in search strategies and update related tests Signed-off-by: Deluan <deluan@navidrome.org> * fix(search): make FTS Phase 1 treat Max=0 as no limit, reorganize tests FTS Phase 1 unconditionally called Limit(uint64(options.Max)), which produced LIMIT 0 when Max was zero. This diverged from applyOptions where Max=0 means no limit. Now Phase 1 mirrors applyOptions: only add LIMIT/OFFSET when the value is positive. Also moved legacy backend integration tests from sql_search_fts_test.go to sql_search_like_test.go and added regression tests for the Max=0 behavior on both backends. * refactor: simplify callSearch function by removing variadic options and directly using QueryOptions Signed-off-by: Deluan <deluan@navidrome.org> * fix(search): implement ftsQueryDegraded function to detect significant content loss in FTS queries Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
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(13)))
|
|
})
|
|
|
|
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", model.QueryOptions{Max: 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", model.QueryOptions{Max: 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", model.QueryOptions{Max: 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", model.QueryOptions{Max: 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", model.QueryOptions{Max: 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", model.QueryOptions{Max: 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", model.QueryOptions{Max: 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())
|
|
})
|
|
})
|
|
})
|