Files
navidrome/persistence/mediafile_repository_test.go
T
Deluan Quintão b59eb32961 feat(subsonic): sort search3 results by relevance (#5086)
* 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>
2026-02-23 08:51:54 -05:00

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())
})
})
})