mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-19 07:37:15 +00:00
f0625ff709
getRandomSongs used a single ORDER BY random() over the media_file table. At large library sizes that forces SQLite to scan the wide table and sort every matching row before applying the limit — roughly 4 seconds for a 1M-track library, regardless of how many songs are requested. Add MediaFileRepository.GetRandom, which does this in two passes: first select N random rowids over a narrow index (filters + library scope only, no wide columns or joins), so the random sort runs over compact, index-friendly data; then hydrate just those rows with the full select. The wide media_file row is never part of the sort. End-to-end this drops getRandomSongs from ~4s to ~0.3s on a 1M-track library, and the cost no longer grows with the requested size. The handler now calls GetRandom directly. The filter helper that builds the genre/year filters is renamed SongsByRandom -> SongsByGenreAndYearRange to reflect what it does, since the random ordering is now owned by GetRandom rather than a Sort option. Filters (genre, year, library) compose into the first pass unchanged. The album random list path is left as-is (far fewer rows, already fast).
970 lines
32 KiB
Go
970 lines
32 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"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("GetRandom", func() {
|
|
It("returns the requested number of distinct, fully-hydrated media files", func() {
|
|
results, err := mr.GetRandom(model.QueryOptions{Max: 5})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(5))
|
|
|
|
// Each returned row must match its GetAll counterpart exactly — proves Phase 2
|
|
// hydrates full rows (not bare rowids) — and ids must be distinct.
|
|
byID := map[string]model.MediaFile{}
|
|
all, err := mr.GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, mf := range all {
|
|
byID[mf.ID] = mf
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, mf := range results {
|
|
expected, ok := byID[mf.ID]
|
|
Expect(ok).To(BeTrue(), "returned id must be a real media file")
|
|
Expect(mf.Title).To(Equal(expected.Title), "row must be fully hydrated")
|
|
Expect(seen[mf.ID]).To(BeFalse(), "no duplicate rows")
|
|
seen[mf.ID] = true
|
|
}
|
|
})
|
|
|
|
It("returns all matching files when Max exceeds the total", func() {
|
|
results, err := mr.GetRandom(model.QueryOptions{Max: 1000})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(HaveLen(13))
|
|
})
|
|
|
|
It("honors filters", func() {
|
|
results, err := mr.GetRandom(model.QueryOptions{
|
|
Max: 10,
|
|
Filters: squirrel.Eq{"media_file.title": "Antenna"},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).ToNot(BeEmpty())
|
|
for _, mf := range results {
|
|
Expect(mf.Title).To(Equal("Antenna"))
|
|
}
|
|
})
|
|
|
|
It("returns varying results across calls", func() {
|
|
// Retry a few times: two random draws of 5 from 13 rows differ with near-certainty.
|
|
first, err := mr.GetRandom(model.QueryOptions{Max: 5})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
firstIDs := func() []string {
|
|
ids := make([]string, len(first))
|
|
for i, mf := range first {
|
|
ids[i] = mf.ID
|
|
}
|
|
return ids
|
|
}()
|
|
differed := false
|
|
for range 10 {
|
|
next, err := mr.GetRandom(model.QueryOptions{Max: 5})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
nextIDs := make([]string, len(next))
|
|
for i, mf := range next {
|
|
nextIDs[i] = mf.ID
|
|
}
|
|
if !reflect.DeepEqual(firstIDs, nextIDs) {
|
|
differed = true
|
|
break
|
|
}
|
|
}
|
|
Expect(differed).To(BeTrue(), "GetRandom should not return an identical set every call")
|
|
})
|
|
|
|
It("randomizes order even when Max exceeds the total", func() {
|
|
// Same set of rows every time (all 13), but the order must still be shuffled —
|
|
// guards against Phase 2's `rowid IN (...)` returning rows in rowid order.
|
|
first, err := mr.GetRandom(model.QueryOptions{Max: 100})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(first).To(HaveLen(13))
|
|
firstIDs := make([]string, len(first))
|
|
for i, mf := range first {
|
|
firstIDs[i] = mf.ID
|
|
}
|
|
differed := false
|
|
for range 10 {
|
|
next, err := mr.GetRandom(model.QueryOptions{Max: 100})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
nextIDs := make([]string, len(next))
|
|
for i, mf := range next {
|
|
nextIDs[i] = mf.ID
|
|
}
|
|
if !reflect.DeepEqual(firstIDs, nextIDs) {
|
|
differed = true
|
|
break
|
|
}
|
|
}
|
|
Expect(differed).To(BeTrue(), "order must vary even when returning all rows")
|
|
})
|
|
})
|
|
|
|
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("path", func() {
|
|
It("matches files whose path starts with the given prefix", func() {
|
|
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
|
Filters: map[string]any{"path": "test/"},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
files := res.(model.MediaFiles)
|
|
|
|
var found bool
|
|
for _, f := range files {
|
|
Expect(f.Path).To(HavePrefix("test/"))
|
|
if f.ID == mfWithoutAnnotation.ID {
|
|
found = true
|
|
}
|
|
}
|
|
Expect(found).To(BeTrue(), "MediaFile with matching path prefix should be included")
|
|
})
|
|
|
|
It("excludes files whose path does not start with the given prefix", func() {
|
|
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
|
Filters: map[string]any{"path": "no-such-prefix/"},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
files := res.(model.MediaFiles)
|
|
Expect(files).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|
|
|
|
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}))
|
|
})
|
|
})
|
|
|
|
Context("empty query (natural order pagination)", func() {
|
|
It("returns all non-missing files in natural order", func() {
|
|
results, err := mr.Search("", model.QueryOptions{Max: 1000})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).ToNot(BeEmpty())
|
|
for _, result := range results {
|
|
Expect(result.Missing).To(BeFalse())
|
|
}
|
|
})
|
|
|
|
It(`treats quoted empty query ("") the same as empty`, func() {
|
|
all, err := mr.Search("", model.QueryOptions{Max: 1000})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
quoted, err := mr.Search(`""`, model.QueryOptions{Max: 1000})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(quoted).To(HaveLen(len(all)))
|
|
})
|
|
|
|
It("paginates without overlaps or gaps", func() {
|
|
all, err := mr.Search("", model.QueryOptions{Max: 1000})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(all)).To(BeNumerically(">", 3))
|
|
|
|
var paged model.MediaFiles
|
|
pageSize := 3
|
|
for offset := 0; offset < len(all); offset += pageSize {
|
|
page, err := mr.Search("", model.QueryOptions{Max: pageSize, Offset: offset})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
paged = append(paged, page...)
|
|
}
|
|
Expect(paged).To(HaveLen(len(all)))
|
|
for i := range all {
|
|
Expect(paged[i].ID).To(Equal(all[i].ID), fmt.Sprintf("row %d differs", i))
|
|
}
|
|
})
|
|
|
|
It("returns empty page when offset is beyond the total", func() {
|
|
results, err := mr.Search("", model.QueryOptions{Max: 10, Offset: 100000})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|
|
|
|
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())
|
|
})
|
|
})
|
|
|
|
Describe("wrapMediaFileCursor", func() {
|
|
It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() {
|
|
// Simulate what queryWithStableResults does on the rows.Err() path:
|
|
// it yields a zero-value dbMediaFile (where MediaFile is nil) with an error.
|
|
dbErr := fmt.Errorf("database is locked")
|
|
cursor := func(yield func(dbMediaFile, error) bool) {
|
|
var empty dbMediaFile // MediaFile pointer is nil
|
|
yield(empty, dbErr)
|
|
}
|
|
|
|
// wrapMediaFileCursor should handle the nil MediaFile without panicking
|
|
wrappedCursor := wrapMediaFileCursor(cursor)
|
|
var gotErr error
|
|
Expect(func() {
|
|
for _, err := range wrappedCursor {
|
|
gotErr = err
|
|
}
|
|
}).ToNot(Panic())
|
|
Expect(gotErr).To(HaveOccurred())
|
|
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile"))
|
|
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
|
|
})
|
|
|
|
It("yields mediafiles from a valid cursor", func() {
|
|
mf := &model.MediaFile{ID: "mf1", Title: "Test"}
|
|
cursor := func(yield func(dbMediaFile, error) bool) {
|
|
yield(dbMediaFile{MediaFile: mf}, nil)
|
|
}
|
|
|
|
wrappedCursor := wrapMediaFileCursor(cursor)
|
|
var mediafiles []model.MediaFile
|
|
for m, err := range wrappedCursor {
|
|
Expect(err).ToNot(HaveOccurred())
|
|
mediafiles = append(mediafiles, m)
|
|
}
|
|
Expect(mediafiles).To(HaveLen(1))
|
|
Expect(mediafiles[0].ID).To(Equal("mf1"))
|
|
})
|
|
})
|
|
|
|
Describe("BPM and BitDepth nullable round-trip", func() {
|
|
It("stores nil BPM and BitDepth as NULL and retrieves them as nil", func() {
|
|
newID := id.NewRandom()
|
|
mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-nil.mp3"}
|
|
Expect(mr.Put(&mf)).To(Succeed())
|
|
|
|
retrieved, err := mr.Get(newID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(retrieved.BPM).To(BeNil())
|
|
Expect(retrieved.BitDepth).To(BeNil())
|
|
|
|
// Also verify via raw SQL that the columns are truly NULL (not 0)
|
|
db := GetDBXBuilder()
|
|
var row struct {
|
|
BPM *int `db:"bpm"`
|
|
BitDepth *int `db:"bit_depth"`
|
|
}
|
|
err = db.NewQuery("SELECT bpm, bit_depth FROM media_file WHERE id={:id}").
|
|
Bind(dbx.Params{"id": newID}).
|
|
One(&row)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(row.BPM).To(BeNil(), "bpm should be stored as NULL in the database")
|
|
Expect(row.BitDepth).To(BeNil(), "bit_depth should be stored as NULL in the database")
|
|
|
|
_ = mr.Delete(newID)
|
|
})
|
|
|
|
It("stores non-nil BPM and BitDepth and retrieves correct values", func() {
|
|
newID := id.NewRandom()
|
|
bpm := 120
|
|
bitDepth := 24
|
|
mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-set.mp3", BPM: &bpm, BitDepth: &bitDepth}
|
|
Expect(mr.Put(&mf)).To(Succeed())
|
|
|
|
retrieved, err := mr.Get(newID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(retrieved.BPM).ToNot(BeNil())
|
|
Expect(*retrieved.BPM).To(Equal(120))
|
|
Expect(retrieved.BitDepth).ToNot(BeNil())
|
|
Expect(*retrieved.BitDepth).To(Equal(24))
|
|
|
|
_ = mr.Delete(newID)
|
|
})
|
|
})
|
|
})
|