navidrome/persistence/mediafile_repository_test.go
Deluan Quintão bfa5b29913
feat: MBID search functionality for albums, artists and songs (#4286)
* feat(subsonic): search by MBID functionality

Updated the search methods in the mediaFileRepository, albumRepository, and artistRepository to support searching by MBID in addition to the existing query methods. This change improves the efficiency of media file, album, and artist searches, allowing for faster retrieval of records based on MBID.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): enhance MBID search functionality for albums and artists

Updated the search functionality to support searching by MBID for both
albums and artists. The fullTextFilter function was modified to accept
additional MBID fields, allowing for more comprehensive searches. New
tests were added to ensure that the search functionality correctly
handles MBID queries, including cases for missing entries and the
includeMissing parameter. This enhancement improves the overall search
capabilities of the application, making it easier for users to find
specific media items by their unique identifiers.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): normalize MBID to lowercase for consistent querying

Updated the MBID handling in the SQL search logic to convert the input
to lowercase before executing the query. This change ensures that
searches are case-insensitive, improving the accuracy and reliability
of the search results when querying by MBID.

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-06-30 17:11:54 -04:00

420 lines
14 KiB
Go

package persistence
import (
"context"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("MediaRepository", func() {
var mr model.MediaFileRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid"})
mr = NewMediaFileRepository(ctx, GetDBXBuilder())
})
It("gets mediafile from the DB", func() {
actual, err := mr.Get("1004")
Expect(err).ToNot(HaveOccurred())
actual.CreatedAt = time.Time{}
Expect(actual).To(Equal(&songAntenna))
})
It("returns ErrNotFound", func() {
_, err := mr.Get("56")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("counts the number of mediafiles in the DB", func() {
Expect(mr.CountAll()).To(Equal(int64(6)))
})
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"))
}
})
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)))
})
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]interface{}{
"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]interface{}{
"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]interface{}{
"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
})
})
})
})
Describe("Search", func() {
Context("text search", func() {
It("finds media files by title", func() {
results, err := mr.Search("Antenna", 0, 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
for _, result := range results {
Expect(result.Title).To(Equal("Antenna"))
}
})
It("finds media files case insensitively", func() {
results, err := mr.Search("antenna", 0, 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
for _, result := range results {
Expect(result.Title).To(Equal("Antenna"))
}
})
It("returns empty result when no matches found", func() {
results, err := mr.Search("nonexistent", 0, 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
})
Context("MBID search", func() {
var mediaFileWithMBID model.MediaFile
var raw *mediaFileRepository
BeforeEach(func() {
raw = mr.(*mediaFileRepository)
// Create a test media file with MBID
mediaFileWithMBID = model.MediaFile{
ID: "test-mbid-mediafile",
Title: "Test MBID MediaFile",
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4
MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4
LibraryID: 1,
Path: "/test/path/test.mp3",
}
// Insert the test media file into the database
err := mr.Put(&mediaFileWithMBID)
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
// Clean up test data using direct SQL
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID}))
})
It("finds media file by mbz_recording_id", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
Expect(results[0].Title).To(Equal("Test MBID MediaFile"))
})
It("finds media file by mbz_release_track_id", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
Expect(results[0].Title).To(Equal("Test MBID MediaFile"))
})
It("returns empty result when MBID is not found", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
It("handles includeMissing parameter for MBID 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())
// Should not find missing media file when includeMissing is false
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
// Should find missing media file when includeMissing is true
results, err = mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10, true)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-missing-mbid-mediafile"))
// Clean up
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID}))
})
})
})
})