mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-03 06:41:01 +00:00
* fix: improve playlist path normalization for cross-platform compatibility Signed-off-by: Deluan <deluan@navidrome.org> * fix: log normalized path when playlist path is not found Signed-off-by: Deluan <deluan@navidrome.org> * test: enhance Unicode normalization tests for playlist paths Signed-off-by: Deluan <deluan@navidrome.org> * fix: enhance playlist path normalization for cross-platform compatibility See https://github.com/navidrome/navidrome/pull/4789#issuecomment-3645724780 Signed-off-by: Deluan <deluan@navidrome.org> * fix: improve playlist path normalization to handle fullwidth characters and enhance cross-platform compatibility Signed-off-by: Deluan <deluan@navidrome.org> * formatting Signed-off-by: Deluan <deluan@navidrome.org> * fix: adjust chunk size for M3U parsing to optimize SQLite expression tree depth Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
653 lines
22 KiB
Go
653 lines
22 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"))
|
||
}
|
||
})
|
||
|
||
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]interface{}{
|
||
"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]interface{}{
|
||
"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]interface{}{
|
||
"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())
|
||
})
|
||
})
|
||
})
|