navidrome/persistence/mediafile_repository_test.go
Deluan Quintão 53d54baef0
fix(jellyfin): stream collection responses to prevent OOM on large libraries (#5783)
* fix(jellyfin): stream /Items responses to prevent OOM on large libraries

Finamp's library sync issues an unbounded GET /Items?IncludeItemTypes=Audio
with Fields=MediaSources and no Limit. On a large library this built the whole
result set — every MediaFile and every BaseItemDto, each fat with MediaSources —
in memory, and json.Encoder then buffered the entire ~200MB response before
writing a byte. Measured on a 96k-track library, one such request peaked near
1.6GB RSS; in a memory-limited container the resulting slowness made clients
retry, stacking concurrent full-library builds until the process was OOM-killed.

Stream the song listing straight from a DB cursor (MediaFileRepository.GetCursor),
mapping and encoding one row at a time, so peak memory is bounded to about one
item regardless of library size. The cursor is opened lazily, when streaming
begins, so it doesn't hold a DB connection across the CountAll and ServerId
lookups that run first (ServerId can write on first use and would deadlock
against an open reader). Pagination still works — the cursor query carries
LIMIT/OFFSET/ORDER BY from StartIndex/Limit/SortBy — and TotalRecordCount stays
the full count. Other materialized responses stream their JSON too, avoiding the
encoder's buffer-the-whole-output cost.

Also bound artwork image concurrency with the same server.ThrottleBacklog that
Subsonic's getCoverArt uses, so a burst of image requests during sync can't
exhaust memory through concurrent decode/resize.

Verified against a copy of a 96k-track production database: byte-for-byte
identical response, peak RSS 1593MB -> 53MB, time-to-first-byte 8.1s -> 0.2s.

* fix(jellyfin): fail loudly on /Items streaming errors instead of a truncated 200

Addresses code review: a streamed /Items response commits HTTP 200 before an
error can occur, so failures were surfacing as misleadingly successful bodies.

- A cursor-open failure (e.g. a busy DB under connection pressure) is now
  surfaced before the first byte is written: the cursor open is deferred into
  the item source and run in writeItems after the ServerId lookup but before the
  envelope, returning a clean 500 the client can retry — not a 200 with an empty
  item list.
- A mid-stream (row-scan) error now aborts without closing the JSON envelope,
  leaving the body malformed. A truncated-but-valid response would let a sync
  client (Finamp) treat the short list as the whole library and prune local
  tracks; malformed JSON forces the client's parser to fail and retry.

* refactor(jellyfin): stream every collection endpoint from a DB cursor

Streaming was applied only to the song listing, which left two write paths, two
ServerId stamping mechanisms and a listXxx return-type split ("songs is
special") that made the code hard to follow.

Add GetCursor to the album, artist, genre and playlist repositories, mirroring
MediaFileRepository.GetCursor: same select builder as GetAll, so each cursor
yields identical, fully-hydrated rows (hydration happens per-row in PostScan,
and toModels is only a deref loop). The playlist cursor keeps GetAll's
owner/public visibility filter. Each repository test asserts the cursor yields
exactly what GetAll returns, including Max/Offset.

With cursors everywhere, every listXxx returns itemsResult and every collection
streams through one writer:

- listAlbums, listArtists and listPlaylists now stream from their cursors;
  search paths stay materialized (Search returns a slice).
- listGenres stays materialized: its total is the length of the full list and it
  paginates in memory, so there is nothing for a cursor to page over.
- api.ok's QueryResult case delegates to writeItems, so the ~9 inline callers
  funnel into the same writer; streamQueryResult and wrapResult are gone.
- /Items/Latest streams as a bare JSON array (its Jellyfin wire shape) via
  writeItemsArray, which shares the item loop and stamping. That also closes the
  same unbounded hole /Items had: limit=0 disabled its LIMIT.
- ServerId is now stamped in exactly one place, so api.ok only handles single,
  non-collection payloads.

Verified against a copy of a 96k-track production database: every endpoint
byte-for-byte identical to master. Unbounded /Items peak RSS 1691MB -> 54MB;
time-to-first-byte 6.3s -> 0.19s, /Artists 1.14s -> 0.13s.

* refactor(jellyfin): route every response through api.ok

Handlers were split between api.ok and api.writeItems with no clear rule for
which to call. api.ok now accepts itemsResult too, so it is the single entry
point: callers hand it whatever they have and it routes collections (cursor
-backed or materialized) to the streaming writer. writeItems is reached only
through api.ok now. The one exception is /Items/Latest, which returns a bare
JSON array rather than a QueryResult envelope and so writes directly — noted in
both doc comments.

Also drop GenreRepository.GetCursor: listGenres derives its total from the
length of the full list and paginates in memory, so there is nothing for a
cursor to page over, leaving the method unused.

* fix(jellyfin): stream the unbounded multi-type /Items merge

The multi-type merge capped each per-type query at offset+limit only when a
Limit was given. Without one (Finamp's favorites screen sends multi-type), every
type ran an unbounded query and collect() drained each cursor into a slice — so
/Items?IncludeItemTypes=MusicAlbum,Audio with no Limit materialized every album
and every song, the same OOM class this branch set out to fix. The comment
claiming the set was "capped at offset+limit per type" was wrong for limit=0.

Without a limit the merged page is just each type's rows in order minus the
first offset, which is exactly what chaining the per-type cursors yields, so
stream that instead of merging in memory. The bounded path is unchanged: with a
Limit each type holds at most offset+limit rows, so merging and paginating
across the combined list is safe. Cursors are opened one at a time (each pins a
DB connection until drained), with the first opened eagerly so the usual failure
is still a clean error before any byte is written.

Measured on a 96k-track library, /Items?IncludeItemTypes=MusicAlbum,Audio with
no Limit (103,393 items): peak RSS 612MB -> 53MB, time-to-first-byte 4.9s ->
0.6s, response byte-for-byte identical.

* refactor(jellyfin): parse /Items params into a struct

The /Items dispatcher was a single 90-line block that parsed a dozen params and
then threaded them positionally through three layers — queryItemsOfType took 11
arguments, listSongs and listAlbums 9 each — so reading any one of them meant
decoding a long argument list.

Parse once into an itemsQuery and pass that instead. queryItems now reads as the
four things it actually does: parse, the id/playlists-folder/playlist-parent
special cases, single-type dispatch, multi-type merge — with the playlist-parent
and merge bodies moved to playlistTracks and mergeTypes. Every listXxx takes
(ctx, opts, q).

No behavior change: parsing, ordering and the entityParent rule are unchanged,
and /Artists still passes favOnly=false explicitly by building the subset of the
query it uses.

* docs(jellyfin): trim comments on the streaming path

Cut the comments back to the non-obvious why: the deferred cursor open (the
ServerId write would deadlock against an open reader), the deliberate malformed
JSON on a mid-stream error, why chained opens one cursor at a time, why
listGenres stays materialized, and why the cursor helpers take the underlying
func type. Dropped the rest — restatements of the code, doc comments that
repeated a test's own name, and the GetCursor interface comments that the
existing MediaFileRepository.GetCursor does without.

* refactor(persistence): fold the cursor wrappers into a generic wrapCursor

wrapAlbumCursor, wrapArtistCursor, wrapMediaFileCursor and wrapFolderCursor were
the same twelve lines four times over, differing only in the type name, and the
playlist cursor had its own inline copy of the loop.

Add one generic wrapCursor. It takes an extractor func rather than a method on
an interface: a type parameter can't reach an embedded field, and methods that
only satisfy a generic constraint are reported by the unused linter. The
extractor also lets both type parameters be inferred, so call sites need no
explicit instantiation. Each entity keeps its named wrapper as a one-liner,
since the model cursor types are defined types and need the conversion — and the
existing wrapper tests call them directly.

The nil-row error now names the model type via %T ("unexpected nil model.Album")
instead of a hand-written per-entity string; the three tests asserting that
message are updated.

* fix(jellyfin): bound concurrent collection streams

A streamed collection holds a DB cursor — and its pooled connection — for the whole client-paced
response, where the old materialize-then-write path released it as soon as the query finished. The
pool is shared with the scanner, Subsonic, the native API and the UI, so enough slow clients take
every connection and everything else blocks waiting for one. Measured against a copy of a 96k-track
production DB, 20 concurrent unbounded streams against a pool of 16 stalled a write for 16.2s (the
other 14 writes in the run took ~2ms — the signature of connection starvation, not lock contention).

Cap concurrent streams at half the pool. Excess requests queue rather than fail, so no client is
rejected: the same 20 streams now all complete and the worst write is 2.5ms.

- conf.MaxOpenConns() now owns the pool sizing (db calls it). It belongs in conf: the pool is a
  tunable, db already imports conf, and putting it in db would force server/jellyfin to import db
  just to size the cap against it. Expressing the cap as MaxOpenConns()/2 also keeps the two from
  drifting apart.
- Uses chi's ThrottleBacklog, not server.ThrottleBacklog: the latter buffers the whole response to
  release its token early, which is right for artwork but would undo the streaming. chi's panics on a
  non-positive limit, so throttleStreams guards it — setting MaxConcurrentStreams=0 disables the cap
  instead of crashing the server at startup.

* refactor(jellyfin): move throttleStreams to middlewares.go

It's a middleware, so it belongs beside normalizeQueryKeys, authenticate and
withPlayer rather than in api.go. Its tests move to middlewares_test.go with it,
keeping one test file per production file.

* fix(jellyfin): abandon the scan when the client stops reading

encodeItems discarded its write errors and relied on the final Flush to report
them, so once bufio's buffer filled and the flush failed, the loop still pulled
every remaining row through the cursor and serialized it for a client that was
gone. A test with a failing writer confirms it: all 20k items were drained.

That wastes CPU, and holds the cursor's pooled DB connection and a stream slot
(now a capped resource) for the length of a full scan nobody is reading. Check
the per-item writes so the first failure ends the scan; the fixed envelope
writes stay unchecked, since bufio latches for them anyway.
2026-07-15 14:07:00 -04:00

1097 lines
37 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/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())
})
Describe("GetCursor", func() {
It("yields the same media files as GetAll", func() {
opts := model.QueryOptions{Sort: "title"}
want, err := mr.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(collectCursor(mr.GetCursor(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(opts)
Expect(err).ToNot(HaveOccurred())
Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want)))
})
})
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("CountAll annotation-join gating", func() {
var adminRepo model.MediaFileRepository
BeforeEach(func() {
adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true})
adminRepo = NewMediaFileRepository(adminCtx, 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(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(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()).To(Equal(int64(13)))
})
})
Describe("CountBySuffix", func() {
var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile
BeforeEach(func() {
mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "test/file.mp3"}
flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "test/file1.flac"}
flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "test/file2.flac"}
flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "test/file.FLAC"}
Expect(mr.Put(&mp3File)).To(Succeed())
Expect(mr.Put(&flacFile1)).To(Succeed())
Expect(mr.Put(&flacFile2)).To(Succeed())
Expect(mr.Put(&flacUpperFile)).To(Succeed())
})
AfterEach(func() {
_ = mr.Delete(mp3File.ID)
_ = mr.Delete(flacFile1.ID)
_ = mr.Delete(flacFile2.ID)
_ = mr.Delete(flacUpperFile.ID)
})
It("counts media files grouped by suffix with lowercase normalization", func() {
counts, err := mr.CountBySuffix()
Expect(err).ToNot(HaveOccurred())
// Should have lowercase keys only
Expect(counts).To(HaveKey("mp3"))
Expect(counts).To(HaveKey("flac"))
Expect(counts).ToNot(HaveKey("FLAC"))
// mp3: 1 file
Expect(counts["mp3"]).To(Equal(int64(1)))
// flac: 3 files (2 lowercase + 1 uppercase normalized)
Expect(counts["flac"]).To(Equal(int64(3)))
})
})
It("returns songs ordered by lyrics with a specific title/artist", func() {
// attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items
results, err := mr.GetAll(model.QueryOptions{
Sort: "lyrics, updated_at",
Order: "desc",
Filters: squirrel.And{
squirrel.Eq{"title": "Antenna"},
squirrel.Or{
Exists("json_tree(participants, '$.albumartist')", squirrel.Eq{"value": "Kraftwerk"}),
Exists("json_tree(participants, '$.artist')", squirrel.Eq{"value": "Kraftwerk"}),
},
},
})
Expect(err).To(BeNil())
Expect(results).To(HaveLen(3))
Expect(results[0].Lyrics).To(Equal(`[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`))
for _, item := range results[1:] {
Expect(item.Lyrics).To(Equal("[]"))
Expect(item.Title).To(Equal("Antenna"))
Expect(item.Participants[model.RoleArtist][0].Name).To(Equal("Kraftwerk"))
}
})
Describe("GetRandom", func() {
It("returns the requested number of distinct, fully-hydrated media files", func() {
results, err := mr.GetRandom(model.QueryOptions{Max: 5})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(5))
// Each returned row must match its GetAll counterpart exactly — proves Phase 2
// hydrates full rows (not bare rowids) — and ids must be distinct.
byID := map[string]model.MediaFile{}
all, err := mr.GetAll()
Expect(err).ToNot(HaveOccurred())
for _, mf := range all {
byID[mf.ID] = mf
}
seen := map[string]bool{}
for _, mf := range results {
expected, ok := byID[mf.ID]
Expect(ok).To(BeTrue(), "returned id must be a real media file")
Expect(mf.Title).To(Equal(expected.Title), "row must be fully hydrated")
Expect(seen[mf.ID]).To(BeFalse(), "no duplicate rows")
seen[mf.ID] = true
}
})
It("returns all matching files when Max exceeds the total", func() {
results, err := mr.GetRandom(model.QueryOptions{Max: 1000})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(13))
})
It("honors filters", func() {
results, err := mr.GetRandom(model.QueryOptions{
Max: 10,
Filters: squirrel.Eq{"media_file.title": "Antenna"},
})
Expect(err).ToNot(HaveOccurred())
Expect(results).ToNot(BeEmpty())
for _, mf := range results {
Expect(mf.Title).To(Equal("Antenna"))
}
})
It("returns varying results across calls", func() {
// Retry a few times: two random draws of 5 from 13 rows differ with near-certainty.
first, err := mr.GetRandom(model.QueryOptions{Max: 5})
Expect(err).ToNot(HaveOccurred())
firstIDs := func() []string {
ids := make([]string, len(first))
for i, mf := range first {
ids[i] = mf.ID
}
return ids
}()
differed := false
for range 10 {
next, err := mr.GetRandom(model.QueryOptions{Max: 5})
Expect(err).ToNot(HaveOccurred())
nextIDs := make([]string, len(next))
for i, mf := range next {
nextIDs[i] = mf.ID
}
if !reflect.DeepEqual(firstIDs, nextIDs) {
differed = true
break
}
}
Expect(differed).To(BeTrue(), "GetRandom should not return an identical set every call")
})
It("randomizes order even when Max exceeds the total", func() {
// Same set of rows every time (all 13), but the order must still be shuffled —
// guards against Phase 2's `rowid IN (...)` returning rows in rowid order.
first, err := mr.GetRandom(model.QueryOptions{Max: 100})
Expect(err).ToNot(HaveOccurred())
Expect(first).To(HaveLen(13))
firstIDs := make([]string, len(first))
for i, mf := range first {
firstIDs[i] = mf.ID
}
differed := false
for range 10 {
next, err := mr.GetRandom(model.QueryOptions{Max: 100})
Expect(err).ToNot(HaveOccurred())
nextIDs := make([]string, len(next))
for i, mf := range next {
nextIDs[i] = mf.ID
}
if !reflect.DeepEqual(firstIDs, nextIDs) {
differed = true
break
}
}
Expect(differed).To(BeTrue(), "order must vary even when returning all rows")
})
})
Describe("Put CreatedAt behavior (#5050)", func() {
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
before := time.Now().Add(-time.Second)
newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "test/created-at-zero.mp3"}
Expect(mr.Put(&newFile)).To(Succeed())
retrieved, err := mr.Get(newFile.ID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.CreatedAt).To(BeTemporally(">", before))
_ = mr.Delete(newFile.ID)
})
It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() {
originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
newFile := model.MediaFile{
ID: id.NewRandom(),
LibraryID: 1,
Path: "test/created-at-preserved.mp3",
CreatedAt: originalTime,
}
Expect(mr.Put(&newFile)).To(Succeed())
retrieved, err := mr.Get(newFile.ID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
_ = mr.Delete(newFile.ID)
})
It("does not reset CreatedAt when updating an existing file", func() {
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
fileID := id.NewRandom()
newFile := model.MediaFile{
ID: fileID,
LibraryID: 1,
Path: "test/created-at-update.mp3",
Title: "Original Title",
CreatedAt: originalTime,
}
Expect(mr.Put(&newFile)).To(Succeed())
// Update the file with a new title but zero CreatedAt
updatedFile := model.MediaFile{
ID: fileID,
LibraryID: 1,
Path: "test/created-at-update.mp3",
Title: "Updated Title",
// CreatedAt is zero - should NOT overwrite the stored value
}
Expect(mr.Put(&updatedFile)).To(Succeed())
retrieved, err := mr.Get(fileID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.Title).To(Equal("Updated Title"))
// CreatedAt should still be the original time (not reset)
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
_ = mr.Delete(fileID)
})
})
It("checks existence of mediafiles in the DB", func() {
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
Expect(mr.Exists("666")).To(BeFalse())
})
It("delete tracks by id", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed())
Expect(mr.Delete(newID)).To(Succeed())
_, err := mr.Get(newID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("deletes all missing files", func() {
new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
Expect(mr.Put(&new1)).To(Succeed())
Expect(mr.Put(&new2)).To(Succeed())
Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed())
adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true})
adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder())
// Ensure the files are marked as missing and we have 2 of them
count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})
Expect(count).To(BeNumerically("==", 2))
Expect(err).ToNot(HaveOccurred())
count, err = adminRepo.DeleteAllMissing()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeNumerically("==", 2))
_, err = mr.Get(new1.ID)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = mr.Get(new2.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
Context("Annotations", func() {
It("increments play count when the tracks does not have annotations", func() {
id := "incplay.firsttime"
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
playDate := time.Now()
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
mf, err := mr.Get(id)
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
Expect(mf.PlayCount).To(Equal(int64(1)))
})
Describe("AverageRating", func() {
var raw *mediaFileRepository
BeforeEach(func() {
raw = mr.(*mediaFileRepository)
})
It("returns 0 when no ratings exist", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(0.0))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
It("returns the user's rating as average when only one user rated", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed())
Expect(mr.SetRating(5, newID)).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(5.0))
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
It("calculates average across multiple users", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed())
Expect(mr.SetRating(3, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
Expect(user2Repo.SetRating(5, newID)).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(4.0))
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
It("excludes zero ratings from average calculation", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed())
Expect(mr.SetRating(4, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
Expect(user2Repo.SetRating(0, newID)).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(4.0))
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
})
It("preserves play date if and only if provided date is older", func() {
id := "incplay.playdate"
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
playDate := time.Now()
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
mf, err := mr.Get(id)
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
Expect(mf.PlayCount).To(Equal(int64(1)))
playDateLate := playDate.AddDate(0, 0, 1)
Expect(mr.IncPlayCount(id, playDateLate)).To(BeNil())
mf, err = mr.Get(id)
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix()))
Expect(mf.PlayCount).To(Equal(int64(2)))
playDateEarly := playDate.AddDate(0, 0, -1)
Expect(mr.IncPlayCount(id, playDateEarly)).To(BeNil())
mf, err = mr.Get(id)
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix()))
Expect(mf.PlayCount).To(Equal(int64(3)))
})
It("increments play count on newly starred items", func() {
id := "star.incplay"
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
Expect(mr.SetStar(true, id)).To(BeNil())
playDate := time.Now()
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
mf, err := mr.Get(id)
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
Expect(mf.PlayCount).To(Equal(int64(1)))
})
})
Context("Sort options", func() {
Context("recently_added sort", func() {
var testMediaFiles []model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Create test media files with specific timestamps
testMediaFiles = []model.MediaFile{
{
ID: id.NewRandom(),
LibraryID: 1,
Title: "Old Song",
Path: "test/old.mp3",
},
{
ID: id.NewRandom(),
LibraryID: 1,
Title: "Middle Song",
Path: "test/middle.mp3",
},
{
ID: id.NewRandom(),
LibraryID: 1,
Title: "New Song",
Path: "test/new.mp3",
},
}
// Insert test data first
for i := range testMediaFiles {
Expect(mr.Put(&testMediaFiles[i])).To(Succeed())
}
// Then manually update timestamps using direct SQL to bypass the repository logic
db := GetDBXBuilder()
// Set specific timestamps for testing
oldTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
middleTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
newTime := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
// Update "Old Song": created long ago, updated recently
_, err := db.Update("media_file",
map[string]any{
"created_at": oldTime,
"updated_at": newTime,
},
dbx.HashExp{"id": testMediaFiles[0].ID}).Execute()
Expect(err).ToNot(HaveOccurred())
// Update "Middle Song": created and updated at the same middle time
_, err = db.Update("media_file",
map[string]any{
"created_at": middleTime,
"updated_at": middleTime,
},
dbx.HashExp{"id": testMediaFiles[1].ID}).Execute()
Expect(err).ToNot(HaveOccurred())
// Update "New Song": created recently, updated long ago
_, err = db.Update("media_file",
map[string]any{
"created_at": newTime,
"updated_at": oldTime,
},
dbx.HashExp{"id": testMediaFiles[2].ID}).Execute()
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
// Clean up test data
for _, mf := range testMediaFiles {
_ = mr.Delete(mf.ID)
}
})
When("RecentlyAddedByModTime is false", func() {
var testRepo model.MediaFileRepository
BeforeEach(func() {
conf.Server.RecentlyAddedByModTime = false
// Create repository AFTER setting config
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid"})
testRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
})
It("sorts by created_at", func() {
// Get results sorted by recently_added (should use created_at)
results, err := testRepo.GetAll(model.QueryOptions{
Sort: "recently_added",
Order: "desc",
Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
// Verify sorting by created_at (newest first in descending order)
Expect(results[0].Title).To(Equal("New Song")) // created 2022
Expect(results[1].Title).To(Equal("Middle Song")) // created 2021
Expect(results[2].Title).To(Equal("Old Song")) // created 2020
})
It("sorts in ascending order when specified", func() {
// Get results sorted by recently_added in ascending order
results, err := testRepo.GetAll(model.QueryOptions{
Sort: "recently_added",
Order: "asc",
Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
// Verify sorting by created_at (oldest first)
Expect(results[0].Title).To(Equal("Old Song")) // created 2020
Expect(results[1].Title).To(Equal("Middle Song")) // created 2021
Expect(results[2].Title).To(Equal("New Song")) // created 2022
})
})
When("RecentlyAddedByModTime is true", func() {
var testRepo model.MediaFileRepository
BeforeEach(func() {
conf.Server.RecentlyAddedByModTime = true
// Create repository AFTER setting config
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid"})
testRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
})
It("sorts by updated_at", func() {
// Get results sorted by recently_added (should use updated_at)
results, err := testRepo.GetAll(model.QueryOptions{
Sort: "recently_added",
Order: "desc",
Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
// Verify sorting by updated_at (newest first in descending order)
Expect(results[0].Title).To(Equal("Old Song")) // updated 2022
Expect(results[1].Title).To(Equal("Middle Song")) // updated 2021
Expect(results[2].Title).To(Equal("New Song")) // updated 2020
})
})
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(ctx, 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(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(&mfWithoutAnnotation)).To(Succeed())
})
AfterEach(func() {
_ = mr.Delete(mfWithoutAnnotation.ID)
})
Describe("starred", func() {
It("false includes items without annotations", func() {
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "false"},
})
Expect(err).ToNot(HaveOccurred())
files := res.(model.MediaFiles)
var found bool
for _, f := range files {
if f.ID == mfWithoutAnnotation.ID {
found = true
break
}
}
Expect(found).To(BeTrue(), "MediaFile without annotation should be included in starred=false filter")
})
It("true excludes items without annotations", func() {
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "true"},
})
Expect(err).ToNot(HaveOccurred())
files := res.(model.MediaFiles)
for _, f := range files {
Expect(f.ID).ToNot(Equal(mfWithoutAnnotation.ID))
}
})
})
Describe("path", func() {
It("matches files whose path starts with the given prefix", func() {
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"path": "test/"},
})
Expect(err).ToNot(HaveOccurred())
files := res.(model.MediaFiles)
var found bool
for _, f := range files {
Expect(f.Path).To(HavePrefix("test/"))
if f.ID == mfWithoutAnnotation.ID {
found = true
}
}
Expect(found).To(BeTrue(), "MediaFile with matching path prefix should be included")
})
It("excludes files whose path does not start with the given prefix", func() {
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"path": "no-such-prefix/"},
})
Expect(err).ToNot(HaveOccurred())
files := res.(model.MediaFiles)
Expect(files).To(BeEmpty())
})
})
})
Describe("Search", func() {
Context("text search", func() {
It("finds media files by title", func() {
results, err := mr.Search("Antenna", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
for _, result := range results {
Expect(result.Title).To(Equal("Antenna"))
}
})
It("finds media files case insensitively", func() {
results, err := mr.Search("antenna", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
for _, result := range results {
Expect(result.Title).To(Equal("Antenna"))
}
})
It("returns empty result when no matches found", func() {
results, err := mr.Search("nonexistent", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
})
Context("MBID search", func() {
var mediaFileWithMBID model.MediaFile
var raw *mediaFileRepository
BeforeEach(func() {
raw = mr.(*mediaFileRepository)
// Create a test media file with MBID
mediaFileWithMBID = model.MediaFile{
ID: "test-mbid-mediafile",
Title: "Test MBID MediaFile",
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4
MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4
LibraryID: 1,
Path: "test/path/test.mp3",
}
// Insert the test media file into the database
err := mr.Put(&mediaFileWithMBID)
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
// Clean up test data using direct SQL
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID}))
})
It("finds media file by mbz_recording_id", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
Expect(results[0].Title).To(Equal("Test MBID MediaFile"))
})
It("finds media file by mbz_release_track_id", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
Expect(results[0].Title).To(Equal("Test MBID MediaFile"))
})
It("returns empty result when MBID is not found", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
It("missing media files are never returned by search", func() {
// Create a missing media file with MBID
missingMediaFile := model.MediaFile{
ID: "test-missing-mbid-mediafile",
Title: "Test Missing MBID MediaFile",
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022",
LibraryID: 1,
Path: "test/path/missing.mp3",
Missing: true,
}
err := mr.Put(&missingMediaFile)
Expect(err).ToNot(HaveOccurred())
// Search never returns missing media files (hardcoded behavior)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
// Clean up
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID}))
})
})
Context("empty query (natural order pagination)", func() {
It("returns all non-missing files in natural order", func() {
results, err := mr.Search("", model.QueryOptions{Max: 1000})
Expect(err).ToNot(HaveOccurred())
Expect(results).ToNot(BeEmpty())
for _, result := range results {
Expect(result.Missing).To(BeFalse())
}
})
It(`treats quoted empty query ("") the same as empty`, func() {
all, err := mr.Search("", model.QueryOptions{Max: 1000})
Expect(err).ToNot(HaveOccurred())
quoted, err := mr.Search(`""`, model.QueryOptions{Max: 1000})
Expect(err).ToNot(HaveOccurred())
Expect(quoted).To(HaveLen(len(all)))
})
It("paginates without overlaps or gaps", func() {
all, err := mr.Search("", model.QueryOptions{Max: 1000})
Expect(err).ToNot(HaveOccurred())
Expect(len(all)).To(BeNumerically(">", 3))
var paged model.MediaFiles
pageSize := 3
for offset := 0; offset < len(all); offset += pageSize {
page, err := mr.Search("", model.QueryOptions{Max: pageSize, Offset: offset})
Expect(err).ToNot(HaveOccurred())
paged = append(paged, page...)
}
Expect(paged).To(HaveLen(len(all)))
for i := range all {
Expect(paged[i].ID).To(Equal(all[i].ID), fmt.Sprintf("row %d differs", i))
}
})
It("returns empty page when offset is beyond the total", func() {
results, err := mr.Search("", model.QueryOptions{Max: 10, Offset: 100000})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
})
})
Describe("FindByPaths", func() {
// Test fixtures for Unicode and case-sensitivity tests
var testFiles []model.MediaFile
BeforeEach(func() {
testFiles = []model.MediaFile{
{ID: "findpath-1", LibraryID: 1, Path: "artist/Album/track.mp3", Title: "Track"},
{ID: "findpath-2", LibraryID: 1, Path: "artist/Album/UPPER.mp3", Title: "Upper"},
// Fullwidth uppercase: (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 should NOT match
// (this is the SQLite limitation that requires exact matching for non-ASCII)
results, err = mr.FindByPaths([]string{"1:plex/02 - .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())
})
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(adminCtx, 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(&otherLib)).To(Succeed())
// A track that lives only in the other library (created as admin)
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
Expect(adminMr.Put(&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(adminCtx, GetDBXBuilder())
Expect(ur.Put(&restrictedUser)).To(Succeed())
Expect(ur.SetUserLibraries(restrictedUser.ID, []int{1})).To(Succeed())
})
AfterEach(func() {
adminCtx := request.WithUser(GinkgoT().Context(), adminUser)
_ = NewMediaFileRepository(adminCtx, GetDBXBuilder()).Delete("otherlib-track")
lr := NewLibraryRepository(adminCtx, GetDBXBuilder()).(*libraryRepository)
_ = lr.delete(squirrel.Eq{"id": otherLib.ID})
_ = NewUserRepository(adminCtx, GetDBXBuilder()).Delete(restrictedUser.ID)
})
It("does not resolve paths in libraries the user cannot access", func() {
userMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), restrictedUser), GetDBXBuilder())
qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID)
results, err := userMr.FindByPaths([]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() {
adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID)
results, err := adminMr.FindByPaths([]string{qualified})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("otherlib-track"))
})
})
})
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(&mf)).To(Succeed())
retrieved, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.BPM).To(BeNil())
Expect(retrieved.BitDepth).To(BeNil())
// Also verify via raw SQL that the columns are truly NULL (not 0)
db := GetDBXBuilder()
var row struct {
BPM *int `db:"bpm"`
BitDepth *int `db:"bit_depth"`
}
err = db.NewQuery("SELECT bpm, bit_depth FROM media_file WHERE id={:id}").
Bind(dbx.Params{"id": newID}).
One(&row)
Expect(err).ToNot(HaveOccurred())
Expect(row.BPM).To(BeNil(), "bpm should be stored as NULL in the database")
Expect(row.BitDepth).To(BeNil(), "bit_depth should be stored as NULL in the database")
_ = mr.Delete(newID)
})
It("stores non-nil BPM and BitDepth and retrieves correct values", func() {
newID := id.NewRandom()
bpm := 120
bitDepth := 24
mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-set.mp3", BPM: &bpm, BitDepth: &bitDepth}
Expect(mr.Put(&mf)).To(Succeed())
retrieved, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.BPM).ToNot(BeNil())
Expect(*retrieved.BPM).To(Equal(120))
Expect(retrieved.BitDepth).ToNot(BeNil())
Expect(*retrieved.BitDepth).To(Equal(24))
_ = mr.Delete(newID)
})
})
})