2026-01-21 10:45:17 -08:00
|
|
|
package persistence
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
|
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
|
|
|
"github.com/deluan/rest"
|
|
|
|
|
"github.com/navidrome/navidrome/model"
|
|
|
|
|
"github.com/navidrome/navidrome/model/request"
|
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
|
|
|
. "github.com/onsi/gomega"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var _ = Describe("Annotation Filters", func() {
|
|
|
|
|
var (
|
|
|
|
|
albumRepo *albumRepository
|
|
|
|
|
albumWithoutAnnotation model.Album
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
ctx := request.WithUser(context.Background(), model.User{ID: "userid", UserName: "johndoe"})
|
|
|
|
|
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
|
|
|
|
|
|
|
|
|
|
// Create album without any annotation (no star, no rating)
|
|
|
|
|
albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1}
|
|
|
|
|
Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
AfterEach(func() {
|
|
|
|
|
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID}))
|
|
|
|
|
})
|
|
|
|
|
|
feat(cli): add missing file list and remap subcommands (#5928)
* feat(cli): add missing file list and remap subcommands
Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com>
* fix: prevent remapping from dropping participants on target track
* fix: after remapping, refresh stats synchronously
* fix: only move album annotations if moving a track would empty the old album
* fix(persistence): keep the new item's annotation when reassigning onto an item the user already annotated
ReassignAnnotation was a plain UPDATE; the annotation table is unique on
(user_id, item_id, item_type), so when a user had annotated both items the
statement aborted and none of the rows moved. In the scanner that surfaced as
a warning; in the missing-file remap it rolled back the whole operation.
UPDATE OR IGNORE moves what it can and leaves the conflicting rows for GC.
* fix(core): keep the target track's history when remapping a missing file onto it
The remap discards the target's row, and GC then dropped its play counts,
stars, ratings, bookmarks and every playlist entry pointing at it. That is
harmless in the scanner, whose target was imported seconds earlier, but the
CLI lets the user pick any existing track. Move those references onto the
surviving id first; where a user already has a row for both, theirs on the
missing file wins.
* fix(persistence): stop FindByPaths dropping plain paths that contain a colon
Any colon was taken as the libraryID separator, and a non-numeric prefix
made the whole path vanish from the lookup. 'missing fix' then rejected the
very paths 'missing list' printed, and M3U imports silently skipped such
tracks. Only a numeric prefix qualifies a path now.
* perf(cli): stream 'missing list' instead of loading every missing file into memory
GetAll materialised the whole result set before a single row was written;
on a library with 97k missing files that peaked at 1.28 GB of RSS. Iterate
the repository cursor and write rows as they arrive.
* refactor(core): tidy the missing-file remap
Drop the log lines copied from deleteMissing that still said 'after deleting
missing files', the debug-on-success branches, and the what-comments; build
the affected album list without slice helpers.
* fix(cli): move path to the last column of 'missing list'
Path is the only variable-width field, so leading with it misaligns every
row that follows. Applies to both csv and json.
* fix(persistence): also try a numeric colon prefix as a plain path
'1999: A Different Life/01.mp3' parsed as library 1999 plus a truncated path
and matched nothing. The prefix is ambiguous, so search both ways.
Also buffer the json branch of 'missing list', which wrote a syscall per row.
* fix(persistence): move scrobbles and buffered scrobbles off a discarded media file
Both tables carry ON DELETE CASCADE on media_file_id, so 'missing fix'
deleting the target erased its play history and dropped scrobbles still
waiting on an external service. scrobble_buffer needs OR IGNORE for its
unique (user_id, service, media_file_id, play_time).
* fix(persistence): recompute the cached average rating after merging annotations
Merging the discarded row's annotations grows the rating population of the
surviving track, so media_file.average_rating no longer matched what the
annotation rows say. Only reachable since the remap started merging those
rows instead of deleting them.
* fix(persistence): recompute the cached average rating inside ReassignAnnotation
Moving annotation rows always changes the new item's rating population, so
the recompute belongs with the move rather than at each call site. Covers
the album reassign in the remap and the two scanner sites, and replaces the
explicit call ReassignReferences was making.
Album was the worse case: rate an album, move its files, and 'missing fix'
handed the rating to an album still caching an average of 0.
* fix(cli): let libraryID:path win over a file literally named like one
FindByPaths searches a numeric-prefixed reference both ways, so a top-level
file named '1:foo.mp3' can tie with library 1's 'foo.mp3'. The CLI then
rejected the reference as ambiguous while advising the exact syntax the
caller had used. Also disambiguates the same path in two libraries, which
is what the qualified form is for.
---------
Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-12 10:08:25 -06:00
|
|
|
Describe("ReassignAnnotation", func() {
|
|
|
|
|
var prev, next model.Album
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
prev = model.Album{ID: "reassign-prev", Name: "Prev", LibraryID: 1}
|
|
|
|
|
next = model.Album{ID: "reassign-next", Name: "Next", LibraryID: 1}
|
|
|
|
|
Expect(albumRepo.Put(&prev)).To(Succeed())
|
|
|
|
|
Expect(albumRepo.Put(&next)).To(Succeed())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
AfterEach(func() {
|
|
|
|
|
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}}))
|
|
|
|
|
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{prev.ID, next.ID}}))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("moves the annotation when the new item has none", func() {
|
|
|
|
|
Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
got, err := albumRepo.Get(next.ID)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(got.Rating).To(Equal(4))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("recomputes the new item's cached average rating", func() {
|
|
|
|
|
Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
got, err := albumRepo.Get(next.ID)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(got.AverageRating).To(Equal(4.0))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("keeps the new item's annotation when both exist", func() {
|
|
|
|
|
Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed())
|
|
|
|
|
Expect(albumRepo.SetRating(2, next.ID)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
got, err := albumRepo.Get(next.ID)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(got.Rating).To(Equal(2))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-21 10:45:17 -08:00
|
|
|
Describe("annotationBoolFilter", func() {
|
|
|
|
|
DescribeTable("creates correct SQL expressions",
|
2026-02-08 08:57:30 -06:00
|
|
|
func(field, value string, expectedSQL string, expectedArgs []any) {
|
2026-01-21 10:45:17 -08:00
|
|
|
sqlizer := annotationBoolFilter(field)(field, value)
|
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(sql).To(Equal(expectedSQL))
|
|
|
|
|
Expect(args).To(Equal(expectedArgs))
|
|
|
|
|
},
|
2026-02-08 08:57:30 -06:00
|
|
|
Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []any(nil)),
|
|
|
|
|
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)),
|
|
|
|
|
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)),
|
|
|
|
|
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)),
|
2026-01-21 10:45:17 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
It("returns nil if value is not a string", func() {
|
|
|
|
|
sqlizer := annotationBoolFilter("starred")("starred", 123)
|
|
|
|
|
Expect(sqlizer).To(BeNil())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("starredFilter", func() {
|
|
|
|
|
It("false includes items without annotations", func() {
|
|
|
|
|
albums, err := albumRepo.GetAll(model.QueryOptions{
|
|
|
|
|
Filters: annotationBoolFilter("starred")("starred", "false"),
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
|
|
|
|
|
var found bool
|
|
|
|
|
for _, a := range albums {
|
|
|
|
|
if a.ID == albumWithoutAnnotation.ID {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Expect(found).To(BeTrue(), "Item without annotation should be included in starred=false filter")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("true excludes items without annotations", func() {
|
|
|
|
|
albums, err := albumRepo.GetAll(model.QueryOptions{
|
|
|
|
|
Filters: annotationBoolFilter("starred")("starred", "true"),
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
|
|
|
|
|
for _, a := range albums {
|
|
|
|
|
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("hasRatingFilter", func() {
|
|
|
|
|
It("false includes items without annotations", func() {
|
|
|
|
|
albums, err := albumRepo.GetAll(model.QueryOptions{
|
|
|
|
|
Filters: annotationBoolFilter("rating")("rating", "false"),
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
|
|
|
|
|
var found bool
|
|
|
|
|
for _, a := range albums {
|
|
|
|
|
if a.ID == albumWithoutAnnotation.ID {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Expect(found).To(BeTrue(), "Item without annotation should be included in has_rating=false filter")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("true excludes items without annotations", func() {
|
|
|
|
|
albums, err := albumRepo.GetAll(model.QueryOptions{
|
|
|
|
|
Filters: annotationBoolFilter("rating")("rating", "true"),
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
|
|
|
|
|
for _, a := range albums {
|
|
|
|
|
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("true includes items with rating > 0", func() {
|
|
|
|
|
// Create album with rating 1
|
|
|
|
|
ratedAlbum := model.Album{ID: "rated-album", Name: "Rated Album", LibraryID: 1}
|
|
|
|
|
Expect(albumRepo.Put(&ratedAlbum)).To(Succeed())
|
|
|
|
|
Expect(albumRepo.SetRating(1, ratedAlbum.ID)).To(Succeed())
|
|
|
|
|
defer func() {
|
|
|
|
|
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID}))
|
|
|
|
|
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID}))
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
albums, err := albumRepo.GetAll(model.QueryOptions{
|
|
|
|
|
Filters: annotationBoolFilter("rating")("rating", "true"),
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
|
|
|
|
|
var found bool
|
|
|
|
|
for _, a := range albums {
|
|
|
|
|
if a.ID == ratedAlbum.ID {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Expect(found).To(BeTrue(), "Album with rating 5 should be included in has_rating=true filter")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("ignores invalid filter values (not strings)", func() {
|
|
|
|
|
res, err := albumRepo.ReadAll(rest.QueryOptions{
|
|
|
|
|
Filters: map[string]any{"starred": 123},
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
albums := res.(model.Albums)
|
|
|
|
|
|
|
|
|
|
var found bool
|
|
|
|
|
for _, a := range albums {
|
|
|
|
|
if a.ID == albumWithoutAnnotation.ID {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Expect(found).To(BeTrue(), "Item without annotation should be included when filter is ignored")
|
|
|
|
|
})
|
2026-06-30 22:50:43 -04:00
|
|
|
|
|
|
|
|
Describe("annotationColumns", func() {
|
|
|
|
|
It("derives the annotation join columns from model.Annotations, excluding average_rating", func() {
|
|
|
|
|
cols := annotationColumns()
|
|
|
|
|
Expect(cols).To(HaveKey("starred"))
|
|
|
|
|
Expect(cols).To(HaveKey("starred_at"))
|
|
|
|
|
Expect(cols).To(HaveKey("rating"))
|
|
|
|
|
Expect(cols).To(HaveKey("rated_at"))
|
|
|
|
|
Expect(cols).To(HaveKey("play_count"))
|
|
|
|
|
Expect(cols).To(HaveKey("play_date"))
|
|
|
|
|
Expect(cols).To(HaveLen(6), "expected exactly the 6 annotation-join columns")
|
|
|
|
|
Expect(cols).ToNot(HaveKey("average_rating"), "average_rating lives on the base table, not the annotation join")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("filtersNeedAnnotation", func() {
|
|
|
|
|
It("is true when the query references an annotation column", func() {
|
|
|
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"starred": true})
|
|
|
|
|
Expect(filtersNeedAnnotation(q)).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is true for a raw expression referencing an annotation column", func() {
|
|
|
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("rating > 0"))
|
|
|
|
|
Expect(filtersNeedAnnotation(q)).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is false for a query that references no annotation column", func() {
|
|
|
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"missing": false})
|
|
|
|
|
Expect(filtersNeedAnnotation(q)).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is false for a filter on average_rating (base-table column, not the annotation rating)", func() {
|
|
|
|
|
// Regression: average_rating must not match the annotation column "rating".
|
|
|
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Gt{"average_rating": 3})
|
|
|
|
|
Expect(filtersNeedAnnotation(q)).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is true when both average_rating and a real annotation column are referenced", func() {
|
|
|
|
|
q := squirrel.Select("count(1)").From("media_file").
|
|
|
|
|
Where(squirrel.Gt{"average_rating": 3}).
|
|
|
|
|
Where(squirrel.Expr("COALESCE(rating, 0) > 0"))
|
|
|
|
|
Expect(filtersNeedAnnotation(q)).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is true for uppercase/mixed-case annotation columns (SQLite is case-insensitive)", func() {
|
|
|
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("RATING > 0"))
|
|
|
|
|
Expect(filtersNeedAnnotation(q)).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is false for uppercase average_rating (still excluded case-insensitively)", func() {
|
|
|
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("AVERAGE_RATING > 3"))
|
|
|
|
|
Expect(filtersNeedAnnotation(q)).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("CountAll annotation-join gating", func() {
|
|
|
|
|
It("counts all items unfiltered (join dropped)", func() {
|
|
|
|
|
total, err := albumRepo.CountAll()
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(total).To(BeNumerically(">=", int64(1)))
|
|
|
|
|
|
|
|
|
|
filtered, err := albumRepo.CountAll(model.QueryOptions{
|
|
|
|
|
Filters: squirrel.Eq{"album.id": albumWithoutAnnotation.ID},
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(filtered).To(Equal(int64(1)))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("counts starred items correctly (named annotation filter keeps the join)", func() {
|
|
|
|
|
starredAlbum := model.Album{ID: "counted-starred-album", Name: "Counted Starred", LibraryID: 1}
|
|
|
|
|
Expect(albumRepo.Put(&starredAlbum)).To(Succeed())
|
|
|
|
|
Expect(albumRepo.SetStar(true, starredAlbum.ID)).To(Succeed())
|
|
|
|
|
defer func() {
|
|
|
|
|
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": starredAlbum.ID}))
|
|
|
|
|
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": starredAlbum.ID}))
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Exactly two albums are starred for this user: the one created above and
|
|
|
|
|
// albumRadioactivity (id 103) from the seed data.
|
|
|
|
|
count, err := albumRepo.CountAll(model.QueryOptions{
|
|
|
|
|
Filters: annotationBoolFilter("starred")("starred", "true"),
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(count).To(Equal(int64(2)))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("counts via a raw annotation filter without a 'no such column' error", func() {
|
|
|
|
|
count, err := albumRepo.CountAll(model.QueryOptions{
|
|
|
|
|
Filters: squirrel.Expr("COALESCE(rating, 0) > 0"),
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(count).To(BeNumerically(">=", int64(0)))
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-01-21 10:45:17 -08:00
|
|
|
})
|