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/criteria" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" ) var _ = Describe("MediaRepository", func() { var mr model.MediaFileRepository var ctx context.Context BeforeEach(func() { ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid"}) mr = NewMediaFileRepository(GetDBXBuilder()) }) Describe("GetAlbumIDsByFolder", func() { var lib model.Library var albumRoot, disc1, sibling *model.Folder BeforeEach(func() { ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) libPtr, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) Expect(err).ToNot(HaveOccurred()) lib = *libPtr folderRepo := newFolderRepository(GetDBXBuilder()) albumRoot = model.NewFolder(lib, "ByFolder/Album") disc1 = model.NewFolder(lib, "ByFolder/Album/CD1") sibling = model.NewFolder(lib, "ByFolder/Other") for _, f := range []*model.Folder{albumRoot, disc1, sibling} { Expect(folderRepo.Put(ctx, f)).To(Succeed()) } // Tracks live in the disc subfolder; the sibling album is the negative control. Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-1", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/1.mp3"})).To(Succeed()) Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-2", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/2.mp3"})).To(Succeed()) Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-3", LibraryID: 1, AlbumID: "fol-al-2", FolderID: sibling.ID, Path: "t/3.mp3"})).To(Succeed()) Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-4", LibraryID: 1, AlbumID: "fol-al-3", FolderID: disc1.ID, Path: "t/4.mp3", Missing: true})).To(Succeed()) DeferCleanup(func() { _, _ = GetDBXBuilder().NewQuery("DELETE FROM media_file WHERE id LIKE 'fol-mf-%'").Execute() _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'ByFolder%' OR name = 'ByFolder'").Execute() }) }) It("returns the distinct album IDs of non-missing tracks in the folder", func() { ids, err := mr.GetAlbumIDsByFolder(ctx, lib, disc1.ID) Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf("fol-al-1")) }) It("also matches albums whose tracks are in a direct child of the folder", func() { // A cover in the album root must reach the album whose tracks sit in CD1 ids, err := mr.GetAlbumIDsByFolder(ctx, lib, albumRoot.ID) Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf("fol-al-1")) }) It("does not match albums outside the folder", func() { ids, err := mr.GetAlbumIDsByFolder(ctx, lib, albumRoot.ID) Expect(err).ToNot(HaveOccurred()) Expect(ids).ToNot(ContainElement("fol-al-2")) }) }) Describe("GetCursor", func() { It("yields the same media files as GetAll", func() { opts := model.QueryOptions{Sort: "title"} want, err := mr.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(collectCursor(mr.GetCursor(ctx, opts))).To(Equal([]model.MediaFile(want))) }) It("honors Max/Offset like GetAll", func() { opts := model.QueryOptions{Sort: "title", Max: 2, Offset: 1} want, err := mr.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(collectCursor(mr.GetCursor(ctx, opts))).To(Equal([]model.MediaFile(want))) }) }) It("gets mediafile from the DB", func() { actual, err := mr.Get(ctx, "1004") Expect(err).ToNot(HaveOccurred()) actual.CreatedAt = time.Time{} Expect(actual).To(Equal(&songAntenna)) }) It("returns ErrNotFound", func() { _, err := mr.Get(ctx, "56") Expect(err).To(MatchError(model.ErrNotFound)) }) It("counts the number of mediafiles in the DB", func() { Expect(mr.CountAll(ctx)).To(Equal(int64(13))) }) Describe("CountAll annotation-join gating", func() { var adminRepo model.MediaFileRepository var adminCtx context.Context BeforeEach(func() { adminCtx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) adminRepo = NewMediaFileRepository(GetDBXBuilder()) }) It("counts starred songs when an annotation filter is present", func() { // Come Together (id 1002) is starred for the admin user in the seed data count, err := adminRepo.CountAll(adminCtx, model.QueryOptions{ Filters: annotationBoolFilter("starred")("starred", "true"), }) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(1))) }) It("counts with starred=false without a 'no such column' error (join kept)", func() { count, err := adminRepo.CountAll(adminCtx, model.QueryOptions{ Filters: annotationBoolFilter("starred")("starred", "false"), }) Expect(err).ToNot(HaveOccurred()) // All songs except the one starred one Expect(count).To(Equal(int64(12))) }) It("counts unfiltered with the join dropped", func() { Expect(adminRepo.CountAll(adminCtx)).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(ctx, &mp3File)).To(Succeed()) Expect(mr.Put(ctx, &flacFile1)).To(Succeed()) Expect(mr.Put(ctx, &flacFile2)).To(Succeed()) Expect(mr.Put(ctx, &flacUpperFile)).To(Succeed()) }) AfterEach(func() { _ = mr.Delete(ctx, mp3File.ID) _ = mr.Delete(ctx, flacFile1.ID) _ = mr.Delete(ctx, flacFile2.ID) _ = mr.Delete(ctx, flacUpperFile.ID) }) It("counts media files grouped by suffix with lowercase normalization", func() { counts, err := mr.CountBySuffix(ctx) 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(ctx, 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(ctx, 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(ctx) 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(ctx, model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(13)) }) It("honors filters", func() { results, err := mr.GetRandom(ctx, 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(ctx, 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(ctx, 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(ctx, 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(ctx, 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(ctx, &newFile)).To(Succeed()) retrieved, err := mr.Get(ctx, newFile.ID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.CreatedAt).To(BeTemporally(">", before)) _ = mr.Delete(ctx, 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(ctx, &newFile)).To(Succeed()) retrieved, err := mr.Get(ctx, newFile.ID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second)) _ = mr.Delete(ctx, 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(ctx, &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(ctx, &updatedFile)).To(Succeed()) retrieved, err := mr.Get(ctx, 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(ctx, fileID) }) }) It("checks existence of mediafiles in the DB", func() { Expect(mr.Exists(ctx, songAntenna.ID)).To(BeTrue()) Expect(mr.Exists(ctx, "666")).To(BeFalse()) }) It("delete tracks by id", func() { newID := id.NewRandom() Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed()) Expect(mr.Delete(ctx, newID)).To(Succeed()) _, err := mr.Get(ctx, 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(ctx, &new1)).To(Succeed()) Expect(mr.Put(ctx, &new2)).To(Succeed()) Expect(mr.MarkMissing(ctx, true, &new1, &new2)).To(Succeed()) adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) adminRepo := NewMediaFileRepository(GetDBXBuilder()) // Ensure the files are marked as missing and we have 2 of them count, err := adminRepo.CountAll(adminCtx, model.QueryOptions{Filters: squirrel.Eq{"missing": true}}) Expect(count).To(BeNumerically("==", 2)) Expect(err).ToNot(HaveOccurred()) count, err = adminRepo.DeleteAllMissing(adminCtx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeNumerically("==", 2)) _, err = mr.Get(ctx, new1.ID) Expect(err).To(MatchError(model.ErrNotFound)) _, err = mr.Get(ctx, 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(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) playDate := time.Now() Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) mf, err := mr.Get(ctx, 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(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed()) mf, err := mr.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(0.0)) _, _ = raw.executeSQL(ctx, 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(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed()) Expect(mr.SetRating(ctx, 5, newID)).To(Succeed()) mf, err := mr.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(5.0)) _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("calculates average across multiple users", func() { newID := id.NewRandom() Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed()) Expect(mr.SetRating(ctx, 3, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) user2Repo := NewMediaFileRepository(GetDBXBuilder()) Expect(user2Repo.SetRating(user2Ctx, 5, newID)).To(Succeed()) mf, err := mr.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(4.0)) _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("excludes zero ratings from average calculation", func() { newID := id.NewRandom() Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed()) Expect(mr.SetRating(ctx, 4, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) user2Repo := NewMediaFileRepository(GetDBXBuilder()) Expect(user2Repo.SetRating(user2Ctx, 0, newID)).To(Succeed()) mf, err := mr.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(4.0)) _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) _, _ = raw.executeSQL(ctx, 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(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) playDate := time.Now() Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) mf, err := mr.Get(ctx, 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(ctx, id, playDateLate)).To(BeNil()) mf, err = mr.Get(ctx, 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(ctx, id, playDateEarly)).To(BeNil()) mf, err = mr.Get(ctx, 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(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) Expect(mr.SetStar(ctx, true, id)).To(BeNil()) playDate := time.Now() Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) mf, err := mr.Get(ctx, 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(ctx, &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(ctx, mf.ID) } }) When("RecentlyAddedByModTime is false", func() { var testRepo model.MediaFileRepository BeforeEach(func() { conf.Server.RecentlyAddedByModTime = false // Create repository AFTER setting config testRepo = NewMediaFileRepository(GetDBXBuilder()) }) It("sorts by created_at", func() { // Get results sorted by recently_added (should use created_at) results, err := testRepo.GetAll(ctx, 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(ctx, 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 testRepo = NewMediaFileRepository(GetDBXBuilder()) }) It("sorts by updated_at", func() { // Get results sorted by recently_added (should use updated_at) results, err := testRepo.GetAll(ctx, 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 }) }) It("breaks ties deterministically when files share the same created_at", func() { conf.Server.RecentlyAddedByModTime = false ctx := log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid"}) repo := NewMediaFileRepository(GetDBXBuilder()) ids := []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID} sameTime := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) _, err := GetDBXBuilder().Update("media_file", dbx.Params{"created_at": sameTime}, dbx.In("id", ids[0], ids[1], ids[2])).Execute() Expect(err).ToNot(HaveOccurred()) order := func() []string { res, err := repo.GetAll(ctx, model.QueryOptions{ Sort: "recently_added", Order: "desc", Filters: squirrel.Eq{"media_file.id": ids}}) Expect(err).ToNot(HaveOccurred()) out := make([]string, len(res)) for i, mf := range res { out[i] = mf.ID } return out } // Stable across repeated queries (no query-plan-dependent reordering). Expect(order()).To(Equal(order())) }) }) }) 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(ctx, &mfWithoutAnnotation)).To(Succeed()) }) AfterEach(func() { _ = mr.Delete(ctx, mfWithoutAnnotation.ID) }) Describe("starred", func() { It("false includes items without annotations", func() { res, err := mr.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": "false"}, }) Expect(err).ToNot(HaveOccurred()) files := res 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.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": "true"}, }) Expect(err).ToNot(HaveOccurred()) files := res 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.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"path": "test/"}, }) Expect(err).ToNot(HaveOccurred()) files := res 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.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"path": "no-such-prefix/"}, }) Expect(err).ToNot(HaveOccurred()) files := res Expect(files).To(BeEmpty()) }) }) }) Describe("Search", func() { Context("text search", func() { It("finds media files by title", func() { results, err := mr.Search(ctx, "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(ctx, "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(ctx, "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(ctx, &mediaFileWithMBID) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { // Clean up test data using direct SQL _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID})) }) It("finds media file by mbz_recording_id", func() { results, err := mr.Search(ctx, "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(ctx, "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(ctx, "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(ctx, &missingMediaFile) Expect(err).ToNot(HaveOccurred()) // Search never returns missing media files (hardcoded behavior) results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) // Clean up _, _ = raw.executeSQL(ctx, 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(ctx, "", 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(ctx, "", model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) quoted, err := mr.Search(ctx, `""`, 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(ctx, "", 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(ctx, "", 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(ctx, "", model.QueryOptions{Max: 10, Offset: 100000}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) }) }) Describe("ReassignReferences", func() { var prev, next model.MediaFile var pr model.PlaylistRepository var pls model.Playlist BeforeEach(func() { ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) pr = NewPlaylistRepository(GetDBXBuilder()) prev = model.MediaFile{ID: "reassign-prev", LibraryID: 1, Path: "reassign/prev.mp3", Title: "Prev"} next = model.MediaFile{ID: "reassign-next", LibraryID: 1, Path: "reassign/next.mp3", Title: "Next"} Expect(mr.Put(ctx, &prev)).To(Succeed()) Expect(mr.Put(ctx, &next)).To(Succeed()) pls = model.Playlist{Name: "Reassign", OwnerID: "userid"} pls.AddMediaFilesByID([]string{prev.ID}) Expect(pr.Put(ctx, &pls)).To(Succeed()) }) AfterEach(func() { _ = pr.Delete(ctx, pls.ID) _ = mr.Delete(ctx, prev.ID) _ = mr.Delete(ctx, next.ID) _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("bookmark").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("scrobbles").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("scrobble_buffer").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) }) It("moves annotations, bookmarks and playlist entries onto the new id", func() { Expect(mr.SetRating(ctx, 5, prev.ID)).To(Succeed()) Expect(mr.AddBookmark(ctx, prev.ID, "here", 42)).To(Succeed()) Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) got, err := mr.Get(ctx, next.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.Rating).To(Equal(5)) bookmarks, err := mr.GetBookmarks(ctx) Expect(err).ToNot(HaveOccurred()) Expect(bookmarks).To(ContainElement(HaveField("Item.ID", next.ID))) withTracks, err := pr.GetWithTracks(ctx, pls.ID, false, false) Expect(err).ToNot(HaveOccurred()) Expect(withTracks.Tracks).To(HaveLen(1)) Expect(withTracks.Tracks[0].MediaFileID).To(Equal(next.ID)) }) It("moves scrobbles and buffered scrobbles onto the new id", func() { ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) scrobbles := NewScrobbleRepository(GetDBXBuilder()) buffer := NewScrobbleBufferRepository(GetDBXBuilder()) Expect(scrobbles.RecordScrobble(ctx, prev.ID, time.Now())).To(Succeed()) Expect(buffer.Enqueue(ctx, "lastfm", "userid", prev.ID, time.Now())).To(Succeed()) Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) Expect(mr.Delete(ctx, prev.ID)).To(Succeed()) all, err := scrobbles.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) mine := slice.Map(slice.Filter(all, func(sc model.Scrobble) bool { return sc.MediaFileID == prev.ID || sc.MediaFileID == next.ID }), func(sc model.Scrobble) string { return sc.MediaFileID }) Expect(mine).To(ConsistOf(next.ID)) entry, err := buffer.Next(ctx, "lastfm", "userid") Expect(err).ToNot(HaveOccurred()) Expect(entry).ToNot(BeNil()) Expect(entry.MediaFile.ID).To(Equal(next.ID)) }) It("recomputes the average rating after merging another user's annotation", func() { otherCtx := request.WithUser(ctx, model.User{ID: "2222"}) other := NewMediaFileRepository(GetDBXBuilder()) Expect(mr.SetRating(ctx, 5, next.ID)).To(Succeed()) Expect(other.SetRating(otherCtx, 3, prev.ID)).To(Succeed()) Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) got, err := mr.Get(ctx, next.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.AverageRating).To(Equal(4.0)) }) It("keeps the new id's own annotation and bookmark when both exist", func() { Expect(mr.SetRating(ctx, 5, prev.ID)).To(Succeed()) Expect(mr.SetRating(ctx, 1, next.ID)).To(Succeed()) Expect(mr.AddBookmark(ctx, prev.ID, "prev", 42)).To(Succeed()) Expect(mr.AddBookmark(ctx, next.ID, "next", 7)).To(Succeed()) Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) got, err := mr.Get(ctx, next.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.Rating).To(Equal(1)) bookmarks, err := mr.GetBookmarks(ctx) Expect(err).ToNot(HaveOccurred()) Expect(bookmarks).To(ContainElement(SatisfyAll(HaveField("Item.ID", next.ID), HaveField("Comment", "next")))) }) }) 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"}, {ID: "findpath-5", LibraryID: 1, Path: "Bach: Goldberg Variations/01.mp3", Title: "Colon"}, {ID: "findpath-6", LibraryID: 1, Path: "1999: A Different Life/01.mp3", Title: "Numeric colon"}, } for _, mf := range testFiles { Expect(mr.Put(ctx, &mf)).To(Succeed()) } }) AfterEach(func() { for _, mf := range testFiles { _ = mr.Delete(ctx, mf.ID) } }) It("treats a path whose prefix is not a library id as unqualified", func() { results, err := mr.FindByPaths(ctx, []string{"Bach: Goldberg Variations/01.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-5")) }) It("finds a plain path whose colon prefix looks like a library id", func() { results, err := mr.FindByPaths(ctx, []string{"1999: A Different Life/01.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-6")) }) It("splits only the first colon of a library-qualified path", func() { results, err := mr.FindByPaths(ctx, []string{"1:Bach: Goldberg Variations/01.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-5")) }) It("finds files by exact path", func() { results, err := mr.FindByPaths(ctx, []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(ctx, []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(ctx, []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(ctx, []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(ctx, []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(ctx, []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(ctx, []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(ctx, []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(ctx, []string{"2:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) Context("when the user has restricted library access", func() { var otherLib model.Library var restrictedUser model.User BeforeEach(func() { adminCtx := request.WithUser(GinkgoT().Context(), adminUser) lr := NewLibraryRepository(GetDBXBuilder()) // A second library the restricted user has no access to otherLib = model.Library{ID: 0, Name: "Other Library", Path: "/other/lib"} Expect(lr.Put(adminCtx, &otherLib)).To(Succeed()) // A track that lives only in the other library (created as admin) adminMr := NewMediaFileRepository(GetDBXBuilder()) Expect(adminMr.Put(adminCtx, &model.MediaFile{ ID: "otherlib-track", LibraryID: otherLib.ID, Path: "hidden/test.mp3", Title: "Hidden", })).To(Succeed()) // Non-admin user with access to library 1 ONLY restrictedUser = createUserWithLibraries("restricted-finder", []int{1}) ur := NewUserRepository(GetDBXBuilder()) Expect(ur.Put(adminCtx, &restrictedUser)).To(Succeed()) Expect(ur.SetUserLibraries(adminCtx, restrictedUser.ID, []int{1})).To(Succeed()) }) AfterEach(func() { adminCtx := request.WithUser(GinkgoT().Context(), adminUser) _ = NewMediaFileRepository(GetDBXBuilder()).Delete(adminCtx, "otherlib-track") lr := NewLibraryRepository(GetDBXBuilder()).(*libraryRepository) _ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID}) _ = NewUserRepository(GetDBXBuilder()).Delete(adminCtx, restrictedUser.ID) }) It("does not resolve paths in libraries the user cannot access", func() { userCtx := request.WithUser(ctx, restrictedUser) userMr := NewMediaFileRepository(GetDBXBuilder()) qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) results, err := userMr.FindByPaths(userCtx, []string{qualified}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty(), "a track outside the user's libraries must not be resolvable") }) It("still resolves the path for an admin", func() { adminCtx := request.WithUser(ctx, adminUser) adminMr := NewMediaFileRepository(GetDBXBuilder()) qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) results, err := adminMr.FindByPaths(adminCtx, []string{qualified}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("otherlib-track")) }) It("resolves paths from multiple libraries in a single call", func() { adminCtx := request.WithUser(ctx, adminUser) adminMr := NewMediaFileRepository(GetDBXBuilder()) results, err := adminMr.FindByPaths(adminCtx, []string{ "1:artist/Album/track.mp3", fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID), }) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) Expect([]string{results[0].ID, results[1].ID}).To(ConsistOf("findpath-1", "otherlib-track")) }) It("keeps each path scoped to its own library when several are queried", func() { adminCtx := request.WithUser(ctx, adminUser) adminMr := NewMediaFileRepository(GetDBXBuilder()) // Each path exists, but under the other library's ID, so neither must match. results, err := adminMr.FindByPaths(adminCtx, []string{ fmt.Sprintf("%d:artist/Album/track.mp3", otherLib.ID), "1:hidden/test.mp3", }) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) }) }) Describe("dbMediaFile.String", func() { It("does not dereference a nil MediaFile", func() { Expect(fmt.Sprint(dbMediaFile{})).To(Equal("")) Expect(fmt.Sprint(&dbMediaFile{})).To(Equal("")) }) }) 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 model.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(ctx, &mf)).To(Succeed()) retrieved, err := mr.Get(ctx, 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(ctx, 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(ctx, &mf)).To(Succeed()) retrieved, err := mr.Get(ctx, 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(ctx, newID) }) }) Describe("AlbumImage hydration", func() { It("carries the parent album's artwork state onto each track", func() { mfs := model.MediaFiles{{ID: "mf-1", AlbumID: "al-1"}} infos := map[string]model.ItemArtworkInfo{ "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8"}, } applyItemImage(infos, mfs[0].AlbumID, &mfs[0].AlbumImage) Expect(mfs[0].AlbumImage.ImageHash).To(Equal("0123456789abcdef")) Expect(mfs[0].AlbumImage.BlurHash).To(Equal("LEHV6nWB2yk8")) Expect(mfs[0].AlbumImage.ImageAbsent).To(BeFalse()) }) It("keeps the album state independent of the track's own art", func() { mf := model.MediaFile{ID: "mf-2", AlbumID: "al-2"} mf.ImageHash = "aaaaaaaaaaaaaaaa" // the track's own art applyItemImage( map[string]model.ItemArtworkInfo{"al-2": {ItemID: "al-2", Hash: "bbbbbbbbbbbbbbbb"}}, mf.AlbumID, &mf.AlbumImage, ) Expect(mf.ImageHash).To(Equal("aaaaaaaaaaaaaaaa")) Expect(mf.AlbumImage.ImageHash).To(Equal("bbbbbbbbbbbbbbbb")) }) }) // Exists must apply the same library filter as Get/GetAll/CountAll. Describe("Exists library visibility", func() { It("hides a track the user has no library access to", func() { restricted := model.User{ID: "restricted_mf_user", UserName: "rm", Name: "RM", Email: "rm@t.com"} rctx := request.WithUser(GinkgoT().Context(), restricted) Expect(mr.Exists(ctx, songAntenna.ID)).To(BeTrue(), "admin sees it") Expect(NewMediaFileRepository(GetDBXBuilder()).Exists(rctx, songAntenna.ID)).To(BeFalse()) }) }) Describe("MatchesCriteria", func() { It("returns true when the track matches", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Day"}}} match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) Expect(err).ToNot(HaveOccurred()) Expect(match).To(BeTrue()) }) It("returns false when the track does not match", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Nickelback"}}} match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) Expect(err).ToNot(HaveOccurred()) Expect(match).To(BeFalse()) }) It("treats missing annotations as their COALESCE default", func() { // unrated track: rating coalesces to 0, so "rating < 4" matches c := criteria.Criteria{Expression: criteria.All{criteria.Lt{"rating": 4}}} match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) Expect(err).ToNot(HaveOccurred()) Expect(match).To(BeTrue()) }) It("returns an error for an invalid field", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Is{"bogusfield": 1}}} _, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) Expect(err).To(HaveOccurred()) }) }) })