mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
Some checks failed
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-1 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-2 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-3 (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-4 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-5 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-6 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-7 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-8 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-9 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-10 (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
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).
969 lines
32 KiB
Go
969 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)
|
||
})
|
||
})
|
||
})
|