navidrome/persistence/mediafile_repository_test.go
Deluan Quintão b293b96256
refactor(persistence): stateless repositories with per-call context (#6149)
* refactor(persistence): adopt generic deluan/rest repository API

Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.

* refactor(persistence): base repository helpers take a context

* refactor(persistence): LibraryRepository takes a context per call

* refactor(persistence): PropertyRepository takes a context per call

* refactor(persistence): UserPropsRepository takes a context per call

* refactor(persistence): TranscodingRepository takes a context per call

* refactor(persistence): ShareRepository takes a context per call

* refactor(persistence): PlayerRepository takes a context per call

* refactor(persistence): RadioRepository takes a context per call

* refactor(persistence): PlayQueueRepository takes a context per call

* refactor(persistence): Tag and Genre repositories take a context per call

* refactor(persistence): PluginRepository takes a context per call

* refactor(persistence): Scrobble repositories take a context per call

* refactor(persistence): FolderRepository takes a context per call

* refactor(persistence): Artwork repositories take a context per call

* refactor(persistence): UserRepository takes a context per call

* refactor(persistence): ArtistRepository takes a context per call

ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.

* test(persistence): assert artist role sort sanitization in ReadAll

* refactor(persistence): AlbumRepository takes a context per call

* test(persistence): pass the test context to album repository helpers

* refactor(persistence): MediaFileRepository takes a context per call

* refactor(persistence): Playlist repositories take a context per call

* refactor(persistence): build all repositories once per store

* refactor(core): REST repository wrappers are built once

* refactor(persistence): repositories are stateless

Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.

* chore(lint): skip containedctx in test files

* refactor: share simplifications from the stateless repositories sweep

Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.

* chore(core): assert REST wrappers implement Persistable

* chore: reformat imports

* perf(persistence): build repositories on first use

Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.

* fix(persistence): clean plugin references per deleted user

A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.

* fix(core): unload disabled plugins even when a user delete fails

A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.

* chore(deps): pin deluan/rest to v1.0.1

Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).

* test: use the spec context instead of context.Background()

Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.

* test: declare the spec context once per Describe

Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
2026-09-25 18:06:10 -04:00

1362 lines
49 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("<nil>"))
Expect(fmt.Sprint(&dbMediaFile{})).To(Equal("<nil>"))
})
})
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())
})
})
})