🏑 index : ironmagma/quintodrome.git

package persistence

import (
	"context"
	"errors"
	"fmt"
	"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("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}))
			})
		})
	})

	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: οΌ‘οΌ£οΌ²οΌ―οΌ³οΌ³ (U+FF21 U+FF23 U+FF32 U+FF2F U+FF33 U+FF33)
				{ID: "findpath-3", LibraryID: 1, Path: "plex/02 - οΌ‘οΌ£οΌ²οΌ―οΌ³οΌ³.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 οΌ‘οΌ£οΌ²οΌ―οΌ³οΌ³, searching with exact match should work
			results, err := mr.FindByPaths([]string{"1:plex/02 - οΌ‘οΌ£οΌ²οΌ―οΌ³οΌ³.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"))
		})
	})
})