package persistence import ( "context" "time" "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" ) var _ = Describe("MediaRepository", func() { var mr model.MediaFileRepository BeforeEach(func() { ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: "userid"}) mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) It("gets mediafile from the DB", func() { actual, err := mr.Get("1004") Expect(err).ToNot(HaveOccurred()) actual.CreatedAt = time.Time{} Expect(actual).To(Equal(&songAntenna)) }) It("returns ErrNotFound", func() { _, err := mr.Get("56") Expect(err).To(MatchError(model.ErrNotFound)) }) It("counts the number of mediafiles in the DB", func() { Expect(mr.CountAll()).To(Equal(int64(13))) }) Describe("CountBySuffix", func() { var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile BeforeEach(func() { mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "/test/file.mp3"} flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "/test/file1.flac"} flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "/test/file2.flac"} flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "/test/file.FLAC"} Expect(mr.Put(&mp3File)).To(Succeed()) Expect(mr.Put(&flacFile1)).To(Succeed()) Expect(mr.Put(&flacFile2)).To(Succeed()) Expect(mr.Put(&flacUpperFile)).To(Succeed()) }) AfterEach(func() { _ = mr.Delete(mp3File.ID) _ = mr.Delete(flacFile1.ID) _ = mr.Delete(flacFile2.ID) _ = mr.Delete(flacUpperFile.ID) }) It("counts media files grouped by suffix with lowercase normalization", func() { counts, err := mr.CountBySuffix() Expect(err).ToNot(HaveOccurred()) // Should have lowercase keys only Expect(counts).To(HaveKey("mp3")) Expect(counts).To(HaveKey("flac")) Expect(counts).ToNot(HaveKey("FLAC")) // mp3: 1 file Expect(counts["mp3"]).To(Equal(int64(1))) // flac: 3 files (2 lowercase + 1 uppercase normalized) Expect(counts["flac"]).To(Equal(int64(3))) }) }) It("returns songs ordered by lyrics with a specific title/artist", func() { // attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items results, err := mr.GetAll(model.QueryOptions{ Sort: "lyrics, updated_at", Order: "desc", Filters: squirrel.And{ squirrel.Eq{"title": "Antenna"}, squirrel.Or{ Exists("json_tree(participants, '$.albumartist')", squirrel.Eq{"value": "Kraftwerk"}), Exists("json_tree(participants, '$.artist')", squirrel.Eq{"value": "Kraftwerk"}), }, }, }) Expect(err).To(BeNil()) Expect(results).To(HaveLen(3)) Expect(results[0].Lyrics).To(Equal(`[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`)) for _, item := range results[1:] { Expect(item.Lyrics).To(Equal("[]")) Expect(item.Title).To(Equal("Antenna")) Expect(item.Participants[model.RoleArtist][0].Name).To(Equal("Kraftwerk")) } }) Describe("Put CreatedAt behavior (#5050)", func() { It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() { before := time.Now().Add(-time.Second) newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"} Expect(mr.Put(&newFile)).To(Succeed()) retrieved, err := mr.Get(newFile.ID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.CreatedAt).To(BeTemporally(">", before)) _ = mr.Delete(newFile.ID) }) It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() { originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC) newFile := model.MediaFile{ ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-preserved.mp3", CreatedAt: originalTime, } Expect(mr.Put(&newFile)).To(Succeed()) retrieved, err := mr.Get(newFile.ID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second)) _ = mr.Delete(newFile.ID) }) It("does not reset CreatedAt when updating an existing file", func() { originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC) fileID := id.NewRandom() newFile := model.MediaFile{ ID: fileID, LibraryID: 1, Path: "/test/created-at-update.mp3", Title: "Original Title", CreatedAt: originalTime, } Expect(mr.Put(&newFile)).To(Succeed()) // Update the file with a new title but zero CreatedAt updatedFile := model.MediaFile{ ID: fileID, LibraryID: 1, Path: "/test/created-at-update.mp3", Title: "Updated Title", // CreatedAt is zero - should NOT overwrite the stored value } Expect(mr.Put(&updatedFile)).To(Succeed()) retrieved, err := mr.Get(fileID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.Title).To(Equal("Updated Title")) // CreatedAt should still be the original time (not reset) Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second)) _ = mr.Delete(fileID) }) }) It("checks existence of mediafiles in the DB", func() { Expect(mr.Exists(songAntenna.ID)).To(BeTrue()) Expect(mr.Exists("666")).To(BeFalse()) }) It("delete tracks by id", func() { newID := id.NewRandom() Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed()) Expect(mr.Delete(newID)).To(Succeed()) _, err := mr.Get(newID) Expect(err).To(MatchError(model.ErrNotFound)) }) It("deletes all missing files", func() { new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} Expect(mr.Put(&new1)).To(Succeed()) Expect(mr.Put(&new2)).To(Succeed()) Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed()) adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder()) // Ensure the files are marked as missing and we have 2 of them count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}}) Expect(count).To(BeNumerically("==", 2)) Expect(err).ToNot(HaveOccurred()) count, err = adminRepo.DeleteAllMissing() Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeNumerically("==", 2)) _, err = mr.Get(new1.ID) Expect(err).To(MatchError(model.ErrNotFound)) _, err = mr.Get(new2.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) Context("Annotations", func() { It("increments play count when the tracks does not have annotations", func() { id := "incplay.firsttime" Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) playDate := time.Now() Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) mf, err := mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) Expect(mf.PlayCount).To(Equal(int64(1))) }) Describe("AverageRating", func() { var raw *mediaFileRepository BeforeEach(func() { raw = mr.(*mediaFileRepository) }) It("returns 0 when no ratings exist", func() { newID := id.NewRandom() Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/no-rating.mp3"})).To(Succeed()) mf, err := mr.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(0.0)) _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("returns the user's rating as average when only one user rated", func() { newID := id.NewRandom() Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/single-rating.mp3"})).To(Succeed()) Expect(mr.SetRating(5, newID)).To(Succeed()) mf, err := mr.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(5.0)) _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("calculates average across multiple users", func() { newID := id.NewRandom() Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/multi-rating.mp3"})).To(Succeed()) Expect(mr.SetRating(3, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder()) Expect(user2Repo.SetRating(5, newID)).To(Succeed()) mf, err := mr.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(4.0)) _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("excludes zero ratings from average calculation", func() { newID := id.NewRandom() Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/zero-excluded.mp3"})).To(Succeed()) Expect(mr.SetRating(4, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder()) Expect(user2Repo.SetRating(0, newID)).To(Succeed()) mf, err := mr.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(4.0)) _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) }) It("preserves play date if and only if provided date is older", func() { id := "incplay.playdate" Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) playDate := time.Now() Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) mf, err := mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) Expect(mf.PlayCount).To(Equal(int64(1))) playDateLate := playDate.AddDate(0, 0, 1) Expect(mr.IncPlayCount(id, playDateLate)).To(BeNil()) mf, err = mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix())) Expect(mf.PlayCount).To(Equal(int64(2))) playDateEarly := playDate.AddDate(0, 0, -1) Expect(mr.IncPlayCount(id, playDateEarly)).To(BeNil()) mf, err = mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix())) Expect(mf.PlayCount).To(Equal(int64(3))) }) It("increments play count on newly starred items", func() { id := "star.incplay" Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) Expect(mr.SetStar(true, id)).To(BeNil()) playDate := time.Now() Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) mf, err := mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) Expect(mf.PlayCount).To(Equal(int64(1))) }) }) Context("Sort options", func() { Context("recently_added sort", func() { var testMediaFiles []model.MediaFile BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) // Create test media files with specific timestamps testMediaFiles = []model.MediaFile{ { ID: id.NewRandom(), LibraryID: 1, Title: "Old Song", Path: "/test/old.mp3", }, { ID: id.NewRandom(), LibraryID: 1, Title: "Middle Song", Path: "/test/middle.mp3", }, { ID: id.NewRandom(), LibraryID: 1, Title: "New Song", Path: "/test/new.mp3", }, } // Insert test data first for i := range testMediaFiles { Expect(mr.Put(&testMediaFiles[i])).To(Succeed()) } // Then manually update timestamps using direct SQL to bypass the repository logic db := GetDBXBuilder() // Set specific timestamps for testing oldTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) middleTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) newTime := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) // Update "Old Song": created long ago, updated recently _, err := db.Update("media_file", map[string]any{ "created_at": oldTime, "updated_at": newTime, }, dbx.HashExp{"id": testMediaFiles[0].ID}).Execute() Expect(err).ToNot(HaveOccurred()) // Update "Middle Song": created and updated at the same middle time _, err = db.Update("media_file", map[string]any{ "created_at": middleTime, "updated_at": middleTime, }, dbx.HashExp{"id": testMediaFiles[1].ID}).Execute() Expect(err).ToNot(HaveOccurred()) // Update "New Song": created recently, updated long ago _, err = db.Update("media_file", map[string]any{ "created_at": newTime, "updated_at": oldTime, }, dbx.HashExp{"id": testMediaFiles[2].ID}).Execute() Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { // Clean up test data for _, mf := range testMediaFiles { _ = mr.Delete(mf.ID) } }) When("RecentlyAddedByModTime is false", func() { var testRepo model.MediaFileRepository BeforeEach(func() { conf.Server.RecentlyAddedByModTime = false // Create repository AFTER setting config ctx := log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid"}) testRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) }) It("sorts by created_at", func() { // Get results sorted by recently_added (should use created_at) results, err := testRepo.GetAll(model.QueryOptions{ Sort: "recently_added", Order: "desc", Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, }) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) // Verify sorting by created_at (newest first in descending order) Expect(results[0].Title).To(Equal("New Song")) // created 2022 Expect(results[1].Title).To(Equal("Middle Song")) // created 2021 Expect(results[2].Title).To(Equal("Old Song")) // created 2020 }) It("sorts in ascending order when specified", func() { // Get results sorted by recently_added in ascending order results, err := testRepo.GetAll(model.QueryOptions{ Sort: "recently_added", Order: "asc", Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, }) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) // Verify sorting by created_at (oldest first) Expect(results[0].Title).To(Equal("Old Song")) // created 2020 Expect(results[1].Title).To(Equal("Middle Song")) // created 2021 Expect(results[2].Title).To(Equal("New Song")) // created 2022 }) }) When("RecentlyAddedByModTime is true", func() { var testRepo model.MediaFileRepository BeforeEach(func() { conf.Server.RecentlyAddedByModTime = true // Create repository AFTER setting config ctx := log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid"}) testRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) }) It("sorts by updated_at", func() { // Get results sorted by recently_added (should use updated_at) results, err := testRepo.GetAll(model.QueryOptions{ Sort: "recently_added", Order: "desc", Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, }) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) // Verify sorting by updated_at (newest first in descending order) Expect(results[0].Title).To(Equal("Old Song")) // updated 2022 Expect(results[1].Title).To(Equal("Middle Song")) // updated 2021 Expect(results[2].Title).To(Equal("New Song")) // updated 2020 }) }) }) }) Context("Filters", func() { var mfWithoutAnnotation model.MediaFile BeforeEach(func() { mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "/test/no-annotation.mp3", Title: "No Annotation"} Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed()) }) AfterEach(func() { _ = mr.Delete(mfWithoutAnnotation.ID) }) Describe("starred", func() { It("false includes items without annotations", func() { res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ Filters: map[string]any{"starred": "false"}, }) Expect(err).ToNot(HaveOccurred()) files := res.(model.MediaFiles) var found bool for _, f := range files { if f.ID == mfWithoutAnnotation.ID { found = true break } } Expect(found).To(BeTrue(), "MediaFile without annotation should be included in starred=false filter") }) It("true excludes items without annotations", func() { res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ Filters: map[string]any{"starred": "true"}, }) Expect(err).ToNot(HaveOccurred()) files := res.(model.MediaFiles) for _, f := range files { Expect(f.ID).ToNot(Equal(mfWithoutAnnotation.ID)) } }) }) }) Describe("Search", func() { Context("text search", func() { It("finds media files by title", func() { results, err := mr.Search("Antenna", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2 for _, result := range results { Expect(result.Title).To(Equal("Antenna")) } }) It("finds media files case insensitively", func() { results, err := mr.Search("antenna", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) for _, result := range results { Expect(result.Title).To(Equal("Antenna")) } }) It("returns empty result when no matches found", func() { results, err := mr.Search("nonexistent", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) }) Context("MBID search", func() { var mediaFileWithMBID model.MediaFile var raw *mediaFileRepository BeforeEach(func() { raw = mr.(*mediaFileRepository) // Create a test media file with MBID mediaFileWithMBID = model.MediaFile{ ID: "test-mbid-mediafile", Title: "Test MBID MediaFile", MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4 MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4 LibraryID: 1, Path: "/test/path/test.mp3", } // Insert the test media file into the database err := mr.Put(&mediaFileWithMBID) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { // Clean up test data using direct SQL _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID})) }) It("finds media file by mbz_recording_id", func() { results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("test-mbid-mediafile")) Expect(results[0].Title).To(Equal("Test MBID MediaFile")) }) It("finds media file by mbz_release_track_id", func() { results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("test-mbid-mediafile")) Expect(results[0].Title).To(Equal("Test MBID MediaFile")) }) It("returns empty result when MBID is not found", func() { results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("missing media files are never returned by search", func() { // Create a missing media file with MBID missingMediaFile := model.MediaFile{ ID: "test-missing-mbid-mediafile", Title: "Test Missing MBID MediaFile", MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022", LibraryID: 1, Path: "/test/path/missing.mp3", Missing: true, } err := mr.Put(&missingMediaFile) Expect(err).ToNot(HaveOccurred()) // Search never returns missing media files (hardcoded behavior) results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) // Clean up _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) }) }) }) Describe("FindByPaths", func() { // Test fixtures for Unicode and case-sensitivity tests var testFiles []model.MediaFile BeforeEach(func() { testFiles = []model.MediaFile{ {ID: "findpath-1", LibraryID: 1, Path: "artist/Album/track.mp3", Title: "Track"}, {ID: "findpath-2", LibraryID: 1, Path: "artist/Album/UPPER.mp3", Title: "Upper"}, // Fullwidth uppercase: ACROSS (U+FF21 U+FF23 U+FF32 U+FF2F U+FF33 U+FF33) {ID: "findpath-3", LibraryID: 1, Path: "plex/02 - ACROSS.flac", Title: "Fullwidth"}, // French diacritic: è (U+00E8, can decompose to e + combining grave) {ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"}, } for _, mf := range testFiles { Expect(mr.Put(&mf)).To(Succeed()) } }) AfterEach(func() { for _, mf := range testFiles { _ = mr.Delete(mf.ID) } }) It("finds files by exact path", func() { results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-1")) }) It("finds files case-insensitively for ASCII characters (NOCASE)", func() { // SQLite's COLLATE NOCASE handles ASCII case-insensitivity results, err := mr.FindByPaths([]string{"1:ARTIST/ALBUM/TRACK.MP3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-1")) }) It("finds fullwidth characters only with exact case match (SQLite NOCASE limitation)", func() { // SQLite's NOCASE does NOT handle fullwidth uppercase/lowercase equivalence // The DB has fullwidth uppercase ACROSS, searching with exact match should work results, err := mr.FindByPaths([]string{"1:plex/02 - ACROSS.flac"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-3")) // Searching with fullwidth lowercase across should NOT match // (this is the SQLite limitation that requires exact matching for non-ASCII) results, err = mr.FindByPaths([]string{"1:plex/02 - across.flac"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("returns multiple files when querying multiple paths", func() { results, err := mr.FindByPaths([]string{ "1:artist/Album/track.mp3", "1:artist/Album/UPPER.mp3", }) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) }) It("returns empty slice for non-existent paths", func() { results, err := mr.FindByPaths([]string{"1:nonexistent/path.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("returns empty slice for empty input", func() { results, err := mr.FindByPaths([]string{}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("handles library-qualified paths correctly", func() { // Library 1 should find the file results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) // Library 2 should NOT find it (file is in library 1) results, err = mr.FindByPaths([]string{"2:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) }) })